mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
fix(macos): harden openclaw deep links
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,8 @@ import Testing
|
||||
uptimems: 123,
|
||||
configpath: nil,
|
||||
statedir: nil,
|
||||
sessiondefaults: nil)
|
||||
sessiondefaults: nil,
|
||||
authmode: nil)
|
||||
|
||||
let hello = HelloOk(
|
||||
type: "hello",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user