diff --git a/CHANGELOG.md b/CHANGELOG.md index 14ec6e4ecb..25d5cd8f54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ Docs: https://docs.openclaw.ai ### Fixes +- macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins. + +## 2026.2.14 + +### Fixes + - Security/Skills: harden archive extraction for download-installed skills to prevent path traversal outside the target directory. Thanks @markmusson. - Security/Signal: harden signal-cli archive extraction during install to prevent path traversal outside the install root. - Security/Hooks: restrict hook transform modules to `~/.openclaw/hooks/transforms` (prevents path traversal/escape module loads via config). Config note: `hooks.transformsDir` must now be within that directory. Thanks @akhmittra. diff --git a/apps/macos/Sources/OpenClaw/DeepLinks.swift b/apps/macos/Sources/OpenClaw/DeepLinks.swift index 13543e658b..bb1fd73b66 100644 --- a/apps/macos/Sources/OpenClaw/DeepLinks.swift +++ b/apps/macos/Sources/OpenClaw/DeepLinks.swift @@ -6,6 +6,43 @@ import Security private let deepLinkLogger = Logger(subsystem: "ai.openclaw", category: "DeepLink") +enum DeepLinkAgentPolicy { + static let maxMessageChars = 20_000 + static let maxUnkeyedConfirmChars = 240 + + enum ValidationError: Error, Equatable, LocalizedError { + case messageTooLongForConfirmation(max: Int, actual: Int) + + var errorDescription: String? { + switch self { + case let .messageTooLongForConfirmation(max, actual): + return "Message is too long to confirm safely (\(actual) chars; max \(max) without key)." + } + } + } + + static func validateMessageForHandle(message: String, allowUnattended: Bool) -> Result { + if !allowUnattended, message.count > self.maxUnkeyedConfirmChars { + return .failure(.messageTooLongForConfirmation(max: self.maxUnkeyedConfirmChars, actual: message.count)) + } + return .success(()) + } + + static func effectiveDelivery( + link: AgentDeepLink, + allowUnattended: Bool) -> (deliver: Bool, to: String?, channel: GatewayAgentChannel) + { + if !allowUnattended { + // Without the unattended key, ignore delivery/routing knobs to reduce exfiltration risk. + return (deliver: false, to: nil, channel: .last) + } + let channel = GatewayAgentChannel(raw: link.channel) + let deliver = channel.shouldDeliver(link.deliver) + let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + return (deliver: deliver, to: to, channel: channel) + } +} + @MainActor final class DeepLinkHandler { static let shared = DeepLinkHandler() @@ -35,7 +72,7 @@ final class DeepLinkHandler { private func handleAgent(link: AgentDeepLink, originalURL: URL) async { let messagePreview = link.message.trimmingCharacters(in: .whitespacesAndNewlines) - if messagePreview.count > 20000 { + if messagePreview.count > DeepLinkAgentPolicy.maxMessageChars { self.presentAlert(title: "Deep link too large", message: "Message exceeds 20,000 characters.") return } @@ -48,9 +85,18 @@ final class DeepLinkHandler { } self.lastPromptAt = Date() - let trimmed = messagePreview.count > 240 ? "\(messagePreview.prefix(240))…" : messagePreview + if case let .failure(error) = DeepLinkAgentPolicy.validateMessageForHandle( + message: messagePreview, + allowUnattended: allowUnattended) + { + self.presentAlert(title: "Deep link blocked", message: error.localizedDescription) + return + } + + let urlText = originalURL.absoluteString + let urlPreview = urlText.count > 500 ? "\(urlText.prefix(500))…" : urlText let body = - "Run the agent with this message?\n\n\(trimmed)\n\nURL:\n\(originalURL.absoluteString)" + "Run the agent with this message?\n\n\(messagePreview)\n\nURL:\n\(urlPreview)" guard self.confirm(title: "Run OpenClaw agent?", message: body) else { return } } @@ -59,7 +105,7 @@ final class DeepLinkHandler { } do { - let channel = GatewayAgentChannel(raw: link.channel) + let effectiveDelivery = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: allowUnattended) let explicitSessionKey = link.sessionKey? .trimmingCharacters(in: .whitespacesAndNewlines) .nonEmpty @@ -72,9 +118,9 @@ final class DeepLinkHandler { message: messagePreview, sessionKey: resolvedSessionKey, thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, - deliver: channel.shouldDeliver(link.deliver), - to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, - channel: channel, + deliver: effectiveDelivery.deliver, + to: effectiveDelivery.to, + channel: effectiveDelivery.channel, timeoutSeconds: link.timeoutSeconds, idempotencyKey: UUID().uuidString) diff --git a/apps/macos/Tests/OpenClawIPCTests/DeepLinkAgentPolicyTests.swift b/apps/macos/Tests/OpenClawIPCTests/DeepLinkAgentPolicyTests.swift new file mode 100644 index 0000000000..ee537f1b62 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/DeepLinkAgentPolicyTests.swift @@ -0,0 +1,77 @@ +import OpenClawKit +import Testing +@testable import OpenClaw + +@Suite struct DeepLinkAgentPolicyTests { + @Test func validateMessageForHandleRejectsTooLongWhenUnkeyed() { + let msg = String(repeating: "a", count: DeepLinkAgentPolicy.maxUnkeyedConfirmChars + 1) + let res = DeepLinkAgentPolicy.validateMessageForHandle(message: msg, allowUnattended: false) + switch res { + case let .failure(error): + #expect( + error == .messageTooLongForConfirmation( + max: DeepLinkAgentPolicy.maxUnkeyedConfirmChars, + actual: DeepLinkAgentPolicy.maxUnkeyedConfirmChars + 1)) + case .success: + Issue.record("expected failure, got success") + } + } + + @Test func validateMessageForHandleAllowsTooLongWhenKeyed() { + let msg = String(repeating: "a", count: DeepLinkAgentPolicy.maxUnkeyedConfirmChars + 1) + let res = DeepLinkAgentPolicy.validateMessageForHandle(message: msg, allowUnattended: true) + switch res { + case .success: + break + case let .failure(error): + Issue.record("expected success, got failure: \(error)") + } + } + + @Test func effectiveDeliveryIgnoresDeliveryFieldsWhenUnkeyed() { + let link = AgentDeepLink( + message: "Hello", + sessionKey: "s", + thinking: "low", + deliver: true, + to: "+15551234567", + channel: "whatsapp", + timeoutSeconds: 10, + key: nil) + let res = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: false) + #expect(res.deliver == false) + #expect(res.to == nil) + #expect(res.channel == .last) + } + + @Test func effectiveDeliveryHonorsDeliverForDeliverableChannelsWhenKeyed() { + let link = AgentDeepLink( + message: "Hello", + sessionKey: "s", + thinking: "low", + deliver: true, + to: " +15551234567 ", + channel: "whatsapp", + timeoutSeconds: 10, + key: "secret") + let res = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: true) + #expect(res.deliver == true) + #expect(res.to == "+15551234567") + #expect(res.channel == .whatsapp) + } + + @Test func effectiveDeliveryStillBlocksWebChatDeliveryWhenKeyed() { + let link = AgentDeepLink( + message: "Hello", + sessionKey: "s", + thinking: "low", + deliver: true, + to: "+15551234567", + channel: "webchat", + timeoutSeconds: 10, + key: "secret") + let res = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: true) + #expect(res.deliver == false) + #expect(res.channel == .webchat) + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift index 046e47886c..661382dda6 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift @@ -12,7 +12,8 @@ import Testing uptimems: 123, configpath: nil, statedir: nil, - sessiondefaults: nil) + sessiondefaults: nil, + authmode: nil) let hello = HelloOk( type: "hello", diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index 58b1d498cd..7f38ba36b0 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -130,6 +130,7 @@ Query parameters: Safety: - Without `key`, the app prompts for confirmation. +- Without `key`, the app enforces a short message limit for the confirmation prompt and ignores `deliver` / `to` / `channel`. - With a valid `key`, the run is unattended (intended for personal automations). ## Onboarding flow (typical)