diff --git a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift index a00d4f8c0..880fb0fa4 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 7ac0dff1d..89ab97748 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 7200af03c..687d696e4 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 bda06e9cf..b80328fcc 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 94edb6ebf..25806e038 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 eea7774ad..a6ff1796c 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 e95cf7a28..9c260ad1d 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 f8b226ab2..459e2686d 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 661382dda..2d26b7c05 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",