mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-28 17:01:53 +03:00
fix(macos): harden openclaw deep links
This commit is contained in:
@@ -6,6 +6,12 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Fixes
|
### 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/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/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.
|
- 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")
|
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
|
@MainActor
|
||||||
final class DeepLinkHandler {
|
final class DeepLinkHandler {
|
||||||
static let shared = DeepLinkHandler()
|
static let shared = DeepLinkHandler()
|
||||||
@@ -35,7 +72,7 @@ final class DeepLinkHandler {
|
|||||||
|
|
||||||
private func handleAgent(link: AgentDeepLink, originalURL: URL) async {
|
private func handleAgent(link: AgentDeepLink, originalURL: URL) async {
|
||||||
let messagePreview = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
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.")
|
self.presentAlert(title: "Deep link too large", message: "Message exceeds 20,000 characters.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -48,9 +85,18 @@ final class DeepLinkHandler {
|
|||||||
}
|
}
|
||||||
self.lastPromptAt = Date()
|
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 =
|
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 }
|
guard self.confirm(title: "Run OpenClaw agent?", message: body) else { return }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +105,7 @@ final class DeepLinkHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let channel = GatewayAgentChannel(raw: link.channel)
|
let effectiveDelivery = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: allowUnattended)
|
||||||
let explicitSessionKey = link.sessionKey?
|
let explicitSessionKey = link.sessionKey?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.nonEmpty
|
.nonEmpty
|
||||||
@@ -72,9 +118,9 @@ final class DeepLinkHandler {
|
|||||||
message: messagePreview,
|
message: messagePreview,
|
||||||
sessionKey: resolvedSessionKey,
|
sessionKey: resolvedSessionKey,
|
||||||
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
||||||
deliver: channel.shouldDeliver(link.deliver),
|
deliver: effectiveDelivery.deliver,
|
||||||
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
to: effectiveDelivery.to,
|
||||||
channel: channel,
|
channel: effectiveDelivery.channel,
|
||||||
timeoutSeconds: link.timeoutSeconds,
|
timeoutSeconds: link.timeoutSeconds,
|
||||||
idempotencyKey: UUID().uuidString)
|
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,
|
uptimems: 123,
|
||||||
configpath: nil,
|
configpath: nil,
|
||||||
statedir: nil,
|
statedir: nil,
|
||||||
sessiondefaults: nil)
|
sessiondefaults: nil,
|
||||||
|
authmode: nil)
|
||||||
|
|
||||||
let hello = HelloOk(
|
let hello = HelloOk(
|
||||||
type: "hello",
|
type: "hello",
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ Query parameters:
|
|||||||
Safety:
|
Safety:
|
||||||
|
|
||||||
- Without `key`, the app prompts for confirmation.
|
- 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).
|
- With a valid `key`, the run is unattended (intended for personal automations).
|
||||||
|
|
||||||
## Onboarding flow (typical)
|
## Onboarding flow (typical)
|
||||||
|
|||||||
Reference in New Issue
Block a user