diff --git a/CHANGELOG.md b/CHANGELOG.md index b20cc48322b..33be220e7c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,6 +109,7 @@ Docs: https://docs.openclaw.ai - Control UI/session routing: preserve established external delivery routes when webchat views or sends in externally originated sessions, so subagent completions still return to the original channel instead of the dashboard. (#47797) Thanks @brokemac79. - Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) Thanks @scoootscooob. - Mattermost/threading: honor `replyToMode: "off"` for already-threaded inbound posts so threaded follow-ups can fall back to top-level replies when configured. (#52543) Thanks @RichardCao. +- Security/exec approvals: escape blank Hangul filler code points in approval prompts across gateway/chat and the macOS native approval UI so visually empty Unicode padding cannot hide reviewed command text. - Telegram/replies: set `allow_sending_without_reply` on reply-targeted sends and media-error notices so deleted parent messages no longer drop otherwise valid replies. (#52524) Thanks @moltbot886. - Gateway/status: resolve env-backed `gateway.auth.*` SecretRefs before read-only probe auth checks so status no longer reports false probe failures when auth is configured through SecretRef. (#52513) Thanks @CodeForgeNet. - Agents/exec: return plain-text failed tool output for timeouts and other non-success exec outcomes so models no longer parrot raw JSON error payloads back to users. (#52508) Thanks @martingarramon. diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalCommandDisplaySanitizer.swift b/apps/macos/Sources/OpenClaw/ExecApprovalCommandDisplaySanitizer.swift new file mode 100644 index 00000000000..4de5c699ad5 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/ExecApprovalCommandDisplaySanitizer.swift @@ -0,0 +1,31 @@ +import Foundation + +enum ExecApprovalCommandDisplaySanitizer { + private static let invisibleCodePoints: Set = [ + 0x115F, + 0x1160, + 0x3164, + 0xFFA0, + ] + + static func sanitize(_ text: String) -> String { + var sanitized = "" + sanitized.reserveCapacity(text.count) + for scalar in text.unicodeScalars { + if self.shouldEscape(scalar) { + sanitized.append(self.escape(scalar)) + } else { + sanitized.append(String(scalar)) + } + } + return sanitized + } + + private static func shouldEscape(_ scalar: UnicodeScalar) -> Bool { + scalar.properties.generalCategory == .format || self.invisibleCodePoints.contains(scalar.value) + } + + private static func escape(_ scalar: UnicodeScalar) -> String { + "\\u{\(String(scalar.value, radix: 16, uppercase: true))}" + } +} diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift index 1187d3d09a4..fe27b23c9a9 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift @@ -271,7 +271,7 @@ enum ExecApprovalsPromptPresenter { commandText.drawsBackground = true commandText.backgroundColor = NSColor.textBackgroundColor commandText.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) - commandText.string = request.command + commandText.string = ExecApprovalCommandDisplaySanitizer.sanitize(request.command) commandText.textContainerInset = NSSize(width: 6, height: 6) commandText.textContainer?.lineFragmentPadding = 0 commandText.textContainer?.widthTracksTextView = true diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalCommandDisplaySanitizerTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalCommandDisplaySanitizerTests.swift new file mode 100644 index 00000000000..34a4dc21534 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalCommandDisplaySanitizerTests.swift @@ -0,0 +1,12 @@ +import Foundation +import Testing +@testable import OpenClaw + +struct ExecApprovalCommandDisplaySanitizerTests { + @Test func `escapes invisible command spoofing characters`() { + let input = "date\u{200B}\u{3164}\u{FFA0}\u{115F}\u{1160}가" + #expect( + ExecApprovalCommandDisplaySanitizer.sanitize(input) == + "date\\u{200B}\\u{3164}\\u{FFA0}\\u{115F}\\u{1160}가") + } +} diff --git a/src/infra/exec-approval-command-display.test.ts b/src/infra/exec-approval-command-display.test.ts index 9fefeec1aed..f89c66ccb1a 100644 --- a/src/infra/exec-approval-command-display.test.ts +++ b/src/infra/exec-approval-command-display.test.ts @@ -8,6 +8,12 @@ describe("sanitizeExecApprovalDisplayText", () => { it("escapes unicode format characters but leaves other text intact", () => { expect(sanitizeExecApprovalDisplayText("echo hi\u200Bthere")).toBe("echo hi\\u{200B}there"); }); + + it("escapes visually blank hangul filler characters used for spoofing", () => { + expect(sanitizeExecApprovalDisplayText("date\u3164\uFFA0\u115F\u1160가")).toBe( + "date\\u{3164}\\u{FFA0}\\u{115F}\\u{1160}가", + ); + }); }); describe("resolveExecApprovalCommandDisplay", () => { diff --git a/src/infra/exec-approval-command-display.ts b/src/infra/exec-approval-command-display.ts index 9ab62e55669..318968b5659 100644 --- a/src/infra/exec-approval-command-display.ts +++ b/src/infra/exec-approval-command-display.ts @@ -1,13 +1,14 @@ import type { ExecApprovalRequestPayload } from "./exec-approvals.js"; -const UNICODE_FORMAT_CHAR_REGEX = /\p{Cf}/gu; +// Escape invisible characters that can spoof approval prompts in common UIs. +const EXEC_APPROVAL_INVISIBLE_CHAR_REGEX = /[\p{Cf}\u115F\u1160\u3164\uFFA0]/gu; function formatCodePointEscape(char: string): string { return `\\u{${char.codePointAt(0)?.toString(16).toUpperCase() ?? "FFFD"}}`; } export function sanitizeExecApprovalDisplayText(commandText: string): string { - return commandText.replace(UNICODE_FORMAT_CHAR_REGEX, formatCodePointEscape); + return commandText.replace(EXEC_APPROVAL_INVISIBLE_CHAR_REGEX, formatCodePointEscape); } function normalizePreview(commandText: string, commandPreview?: string | null): string | null {