OpenClaw macOS deep link confirmation truncation can conceal executed agent message
Description
OpenClaw is a personal AI assistant. OpenClaw macOS desktop client registers the openclaw:// URL scheme. For openclaw://agent deep links without an unattended key, the app shows a confirmation dialog that previously displayed only the first 240 characters of the message, but executed the full message after the user clicked "Run." At the time of writing, the OpenClaw macOS desktop client is still in beta. In versions 2026.2.6 through 2026.2.13, an attacker could pad the message with whitespace to push a malicious payload outside the visible preview, increasing the chance a user approves a different message than the one that is actually executed. If a user runs the deep link, the agent may perform actions that can lead to arbitrary command execution depending on the user's configured tool approvals/allowlists. This is a social-engineering mediated vulnerability: the confirmation prompt could be made to misrepresent the executed message. The issue is fixed in 2026.2.14. Other mitigations include not approve unexpected "Run OpenClaw agent?" prompts triggered while browsing untrusted sites and usingunattended deep links only with a valid key for trusted personal automations.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | >= 2026.2.6-0, < 2026.2.14 | 2026.2.14 |
Affected products
1Patches
128d9dd7a7725fix(macos): harden openclaw deep links
5 files changed · +139 −8
apps/macos/Sources/OpenClaw/DeepLinks.swift+53 −7 modified@@ -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<Void, ValidationError> { + 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)
apps/macos/Tests/OpenClawIPCTests/DeepLinkAgentPolicyTests.swift+77 −0 added@@ -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) + } +}
apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift+2 −1 modified@@ -12,7 +12,8 @@ import Testing uptimems: 123, configpath: nil, statedir: nil, - sessiondefaults: nil) + sessiondefaults: nil, + authmode: nil) let hello = HelloOk( type: "hello",
CHANGELOG.md+6 −0 modified@@ -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.
docs/platforms/macos.md+1 −0 modified@@ -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)
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-7q2j-c4q5-rm27ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-26320ghsaADVISORY
- github.com/openclaw/openclaw/commit/28d9dd7a772501ccc3f71457b4adfee79084fe6fghsax_refsource_MISCWEB
- github.com/openclaw/openclaw/releases/tag/v2026.2.14ghsax_refsource_MISCWEB
- github.com/openclaw/openclaw/security/advisories/GHSA-7q2j-c4q5-rm27ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.