diff --git a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift index a00d4f8c00a..880fb0fa497 100644 --- a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift +++ b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift @@ -194,11 +194,13 @@ struct ExecCommandResolution: Sendable { continue } + if !inSingle, self.shouldFailClosedForShell(ch: ch, next: next) { + // Fail closed on command/process substitution in allowlist mode, + // including inside double-quoted shell strings. + return nil + } + if !inSingle, !inDouble { - if self.shouldFailClosedForUnquotedShell(ch: ch, next: next) { - // Fail closed on command/process substitution in allowlist mode. - return nil - } 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 } @@ -216,7 +218,7 @@ struct ExecCommandResolution: Sendable { return segments } - private static func shouldFailClosedForUnquotedShell(ch: Character, next: Character?) -> Bool { + private static func shouldFailClosedForShell(ch: Character, next: Character?) -> Bool { if ch == "`" { return true } diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift index 7ac0dff1dee..89ab97748ac 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift @@ -80,6 +80,26 @@ struct ExecAllowlistTests { #expect(resolutions.isEmpty) } + @Test func resolveForAllowlistFailsClosedOnQuotedCommandSubstitution() { + let command = ["/bin/sh", "-lc", "echo \"ok $(/usr/bin/touch /tmp/openclaw-allowlist-test-quoted-subst)\""] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: "echo \"ok $(/usr/bin/touch /tmp/openclaw-allowlist-test-quoted-subst)\"", + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.isEmpty) + } + + @Test func resolveForAllowlistFailsClosedOnQuotedBackticks() { + let command = ["/bin/sh", "-lc", "echo \"ok `/usr/bin/id`\""] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: "echo \"ok `/usr/bin/id`\"", + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.isEmpty) + } + @Test func resolveForAllowlistTreatsPlainShInvocationAsDirectExec() { let command = ["/bin/sh", "./script.sh"] let resolutions = ExecCommandResolution.resolveForAllowlist( diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift index 7200af03cdd..687d696e4c6 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift @@ -70,6 +70,10 @@ import Testing handler?(Result.success(.data(response))) } + func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) { + pongReceiveHandler(nil) + } + func receive() async throws -> URLSessionWebSocketTask.Message { if self.helloDelayMs > 0 { try await Task.sleep(nanoseconds: UInt64(self.helloDelayMs) * 1_000_000) diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift index bda06e9cf56..b80328fcc9f 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift @@ -53,6 +53,10 @@ import Testing } } + func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) { + pongReceiveHandler(nil) + } + func receive() async throws -> URLSessionWebSocketTask.Message { let delayMs: Int let msg: URLSessionWebSocketTask.Message diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift index 94edb6ebf77..25806e0384a 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift @@ -62,6 +62,10 @@ import Testing } } + func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) { + pongReceiveHandler(nil) + } + func receive() async throws -> URLSessionWebSocketTask.Message { let id = self.connectRequestID.withLock { $0 } ?? "connect" return .data(Self.connectOkData(id: id)) diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift index eea7774adf2..a6ff1796c51 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift @@ -47,6 +47,10 @@ import Testing } } + func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) { + pongReceiveHandler(nil) + } + func receive() async throws -> URLSessionWebSocketTask.Message { let id = self.connectRequestID.withLock { $0 } ?? "connect" return .data(Self.connectOkData(id: id)) diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift index e95cf7a282d..9c260ad1d2e 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift @@ -15,6 +15,10 @@ private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable { func send(_: URLSessionWebSocketTask.Message) async throws {} + func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) { + pongReceiveHandler(nil) + } + func receive() async throws -> URLSessionWebSocketTask.Message { throw URLError(.cannotConnectToHost) } diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift index f8b226ab277..459e2686d8b 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift @@ -64,6 +64,10 @@ struct GatewayProcessManagerTests { handler?(Result.success(.data(response))) } + func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) { + pongReceiveHandler(nil) + } + func receive() async throws -> URLSessionWebSocketTask.Message { let id = self.connectRequestID.withLock { $0 } ?? "connect" return .data(Self.connectOkData(id: id)) diff --git a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift index 661382dda69..2d26b7c0538 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift @@ -13,7 +13,8 @@ import Testing configpath: nil, statedir: nil, sessiondefaults: nil, - authmode: nil) + authmode: nil, + updateavailable: nil) let hello = HelloOk( type: "hello",