From 2bad30b4d3b300b314f9968544db9396e06888e9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 13:42:36 +0000 Subject: [PATCH 1/8] chore(release): bump version to 2026.2.24 --- CHANGELOG.md | 2 +- apps/android/app/build.gradle.kts | 2 +- apps/ios/Sources/Info.plist | 2 +- apps/ios/Tests/Info.plist | 2 +- apps/macos/Sources/OpenClaw/Resources/Info.plist | 2 +- docs/platforms/mac/release.md | 14 +++++++------- package.json | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 368a6561482..8d61997f5c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ Docs: https://docs.openclaw.ai -## Unreleased +## 2026.2.24 (Unreleased) ### Breaking diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 52e1014e7ba..ad3718b1138 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -22,7 +22,7 @@ android { minSdk = 31 targetSdk = 36 versionCode = 202602230 - versionName = "2026.2.23" + versionName = "2026.2.24" ndk { // Support all major ABIs — native libs are tiny (~47 KB per ABI) abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index c34fccb5052..28633cc370b 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.23 + 2026.2.24 CFBundleURLTypes diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist index a3420e27321..2dca88f97f1 100644 --- a/apps/ios/Tests/Info.plist +++ b/apps/ios/Tests/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 2026.2.23 + 2026.2.24 CFBundleVersion 20260223 diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index 3a425368d09..02928da0eb8 100644 --- a/apps/macos/Sources/OpenClaw/Resources/Info.plist +++ b/apps/macos/Sources/OpenClaw/Resources/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.23 + 2026.2.24 CFBundleVersion 202602230 CFBundleIconFile diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index 029ab3eed93..61180e77aab 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -34,17 +34,17 @@ Notes: # From repo root; set release IDs so Sparkle feed is enabled. # APP_BUILD must be numeric + monotonic for Sparkle compare. BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.2.23 \ +APP_VERSION=2026.2.24 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-app.sh # Zip for distribution (includes resource forks for Sparkle delta support) -ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.23.zip +ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.24.zip # Optional: also build a styled DMG for humans (drag to /Applications) -scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.23.dmg +scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.24.dmg # Recommended: build + notarize/staple zip + DMG # First, create a keychain profile once: @@ -52,14 +52,14 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.23.dmg # --apple-id "" --team-id "" --password "" NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \ BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.2.23 \ +APP_VERSION=2026.2.24 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-dist.sh # Optional: ship dSYM alongside the release -ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.23.dSYM.zip +ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.24.dSYM.zip ``` ## Appcast entry @@ -67,7 +67,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl Use the release note generator so Sparkle renders formatted HTML notes: ```bash -SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.23.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml +SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.24.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml ``` Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. @@ -75,7 +75,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when ## Publish & verify -- Upload `OpenClaw-2026.2.23.zip` (and `OpenClaw-2026.2.23.dSYM.zip`) to the GitHub release for tag `v2026.2.23`. +- Upload `OpenClaw-2026.2.24.zip` (and `OpenClaw-2026.2.24.dSYM.zip`) to the GitHub release for tag `v2026.2.24`. - Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`. - Sanity checks: - `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200. diff --git a/package.json b/package.json index be8ec9577e1..c1b68821083 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw", - "version": "2026.2.23", + "version": "2026.2.24", "description": "Multi-channel AI gateway with extensible messaging integrations", "keywords": [], "homepage": "https://github.com/openclaw/openclaw#readme", From 96b21f48234b30c4f770acc1fac9a4709ab43256 Mon Sep 17 00:00:00 2001 From: yingchunbai <33477283+yingchunbai@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:26:20 +0800 Subject: [PATCH 2/8] fix(macos): remove self-delegate on cost usage submenu to prevent recursive dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cost usage submenu set `menu.delegate = self` (the MenuSessionsInjector), which caused `menuWillOpen(_:)` to call `inject(into:)` on the submenu when it opened. This re-inserted the "Usage cost (30 days)" item into the submenu, creating an infinite recursive dropdown. Fix: remove the delegate assignment from the submenu — it does not need the injector's delegate behavior since it only contains a static chart view. Closes #25167 Co-Authored-By: Claude Opus 4.6 --- apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift index 37fd6ca2505..fde2d0e0bdb 100644 --- a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift +++ b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift @@ -493,7 +493,6 @@ extension MenuSessionsInjector { guard !summary.daily.isEmpty else { return nil } let menu = NSMenu() - menu.delegate = self let chartView = CostUsageHistoryMenuView(summary: summary, width: width) let hosting = NSHostingView(rootView: AnyView(chartView)) From 7c99a733a97e4115ff9e18adb07b74165dfc4521 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 13:48:37 +0000 Subject: [PATCH 3/8] fix: harden macOS usage cost submenu recursion guard (#25341) (thanks @yingchunbai) --- CHANGELOG.md | 1 + .../OpenClaw/MenuSessionsInjector.swift | 8 ++++ .../MenuSessionsInjectorTests.swift | 41 +++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d61997f5c8..3585db45b99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. - Control UI/Chat images: harden image-open clicks against reverse tabnabbing by using opener isolation (`noopener,noreferrer` plus `window.opener = null`). (#18685) Thanks @Mariana-Codebase. - Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. - Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. diff --git a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift index fde2d0e0bdb..eb6271d0a8c 100644 --- a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift +++ b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift @@ -446,6 +446,8 @@ extension MenuSessionsInjector { private func buildUsageOverflowMenu(rows: [UsageRow], width: CGFloat) -> NSMenu { let menu = NSMenu() + // Keep submenu delegate nil: reusing the status-menu delegate here causes + // recursive reinjection whenever this submenu is opened. for row in rows { let item = NSMenuItem() item.tag = self.tag @@ -1225,6 +1227,12 @@ extension MenuSessionsInjector { self.usageCacheUpdatedAt = Date() } + func setTestingCostUsageSummary(_ summary: GatewayCostUsageSummary?, errorText: String? = nil) { + self.cachedCostSummary = summary + self.cachedCostErrorText = errorText + self.costCacheUpdatedAt = Date() + } + func injectForTesting(into menu: NSMenu) { self.inject(into: menu) } diff --git a/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift b/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift index 8395ed145ce..ff63673b9e0 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift @@ -93,4 +93,45 @@ struct MenuSessionsInjectorTests { #expect(menu.items.contains { $0.tag == 9_415_557 }) #expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem }) } + + @Test func costUsageSubmenuDoesNotUseInjectorDelegate() { + let injector = MenuSessionsInjector() + injector.setTestingControlChannelConnected(true) + + let summary = GatewayCostUsageSummary( + updatedAt: Date().timeIntervalSince1970 * 1000, + days: 1, + daily: [ + GatewayCostUsageDay( + date: "2026-02-24", + input: 10, + output: 20, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 30, + totalCost: 0.12, + missingCostEntries: 0), + ], + totals: GatewayCostUsageTotals( + input: 10, + output: 20, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 30, + totalCost: 0.12, + missingCostEntries: 0)) + injector.setTestingCostUsageSummary(summary, errorText: nil) + + let menu = NSMenu() + menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: "")) + + injector.injectForTesting(into: menu) + + let usageCostItem = menu.items.first { $0.title == "Usage cost (30 days)" } + #expect(usageCostItem != nil) + #expect(usageCostItem?.submenu != nil) + #expect(usageCostItem?.submenu?.delegate == nil) + } } From e3ac491da35372d5ce1a14fb5b770c82ca32d778 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 13:51:38 +0000 Subject: [PATCH 4/8] docs(changelog): trim 2026.2.24 unreleased entries --- CHANGELOG.md | 59 +--------------------------------------------------- 1 file changed, 1 insertion(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3585db45b99..ce418f75452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,72 +4,15 @@ Docs: https://docs.openclaw.ai ## 2026.2.24 (Unreleased) -### Breaking - -- **BREAKING:** non-loopback Control UI now requires explicit `gateway.controlUi.allowedOrigins` (full origins). Startup fails closed when missing unless `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` is set to use Host-header origin fallback mode. -- **BREAKING:** channel `allowFrom` matching is now ID-only by default across channels that previously allowed mutable name/tag/email principal matching. If you relied on direct mutable-name matching, migrate allowlists to stable IDs (recommended) or explicitly opt back in with `channels..dangerouslyAllowNameMatching=true` (break-glass compatibility mode). (#24907) - ### Changes -- Subagents/Sessions: add `agents.defaults.subagents.runTimeoutSeconds` so `sessions_spawn` can inherit a configurable default timeout when the tool call omits `runTimeoutSeconds` (unset remains `0`, meaning no timeout). (#24594) Thanks @mitchmcalister. -- Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. -- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants), accept trailing punctuation (for example `STOP OPENCLAW!!!`), and add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms) so emergency stop messages are caught more reliably. Thanks @steipete and @vincentkoc. +- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants), accept trailing punctuation (for example `STOP OPENCLAW!!!`), and add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms) so emergency stop messages are caught more reliably. (#25103) Thanks @steipete and @vincentkoc. ### Fixes - macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. - Control UI/Chat images: harden image-open clicks against reverse tabnabbing by using opener isolation (`noopener,noreferrer` plus `window.opener = null`). (#18685) Thanks @Mariana-Codebase. -- Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. -- Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. -- Security/Session export: harden exported HTML image rendering against data-URL attribute injection by validating image MIME/base64 fields, rejecting malformed base64 input in media ingestion paths, and dropping invalid tool-image payloads. -- Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Sandbox: enforce `tools.exec.applyPatch.workspaceOnly` and `tools.fs.workspaceOnly` for `apply_patch` in sandbox-mounted paths so writes/deletes cannot escape the workspace boundary via mounts like `/agent` unless explicitly opted out (`tools.exec.applyPatch.workspaceOnly=false`). This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Commands: enforce sender-only matching for `commands.allowFrom` by blocking conversation-shaped `From` identities (`channel:`, `group:`, `thread:`, `@g.us`) while preserving direct-message fallback when sender fields are missing. Ships in the next npm release. Thanks @jiseoung. -- Security/Config writes: block reserved prototype keys in account-id normalization and route account config resolution through own-key lookups, hardening `/allowlist` and account-scoped config paths against prototype-chain pollution. -- Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings. -- Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec approvals: restore two-phase approval registration + wait-decision handling for gateway/node exec paths, requiring approval IDs to be registered before returning `approval-pending` and honoring server-assigned approval IDs during wait resolution to prevent orphaned `/approve` flows and immediate-return races (`ask:on-miss`). This ships in the next npm release. Thanks @vitalyis for reporting. -- Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec approvals: recognize `busybox`/`toybox` shell applets in wrapper analysis and allow-always persistence, persist inner executables instead of multiplexer wrapper binaries, and fail closed when multiplexer unwrapping is unsafe to prevent allow-always bypasses. This ships in the next npm release. Thanks @jiseoung for reporting. -- Security/Exec approvals: for non-default setups that enable `autoAllowSkills`, require pathless invocations plus trusted resolved-path matches so `./`/absolute-path basename collisions cannot satisfy skill auto-allow checks under allowlist mode. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. This ships in the next npm release. Thanks @tdjackey and @jiseoung for reporting. -- Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. -- Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky. -- WhatsApp/Auto-reply: send only final payloads to WhatsApp, suppress tool/block payload leakage (reasoning/thinking), and force block streaming off for WhatsApp dispatch so final-only delivery cannot cause silent turns. (#24962) Thanks @SidQin-cyber. -- Channels/Reasoning: suppress reasoning/thinking payload segments in the shared channel dispatch path so non-Telegram channels (including WhatsApp and Web) no longer emit internal reasoning blocks as user-visible replies. (#24991) Thanks @stakeswky. -- Discord/Reasoning: suppress reasoning/thinking-only payload blocks from Discord delivery output. (#24969) -- WhatsApp/DM routing: only update main-session last-route state when DM traffic is bound to the main session, preserving isolated `dmScope` routing. (#24949) Thanks @kevinWangSheng. -- WhatsApp/Access control: honor `selfChatMode` in inbound access-control checks. (#24738) -- WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. -- Discord/Threading: recover missing thread parent IDs by refetching thread metadata before resolving parent channel context. (#24897) Thanks @z-x-yang. -- Web UI/i18n: load and hydrate saved locale translations during startup so non-English sessions apply immediately without manual toggling. (#24795) Thanks @chilu18. -- Gateway/Browser control: load `src/browser/server.js` during browser-control startup so the control listener starts reliably when browser control is enabled. (#23974) Thanks @ieaves. -- Browser/Chrome relay: harden debugger detach handling during full-page navigation with bounded auto-reattach retries and better cancellation behavior for user/devtools detaches. (#19766) Thanks @nishantkabra77. -- Browser/Chrome extension options: validate relay `/json/version` payload shape and content type (not just HTTP status) to detect wrong-port gateway checks, and clarify relay port derivation for custom gateway ports (`gateway + 3`). (#22252) Thanks @krizpoon. -- Status/Pairing recovery: show explicit pairing-approval command hints (including requestId when safe) when gateway probe failures report pairing-required closures. (#24771) Thanks @markmusson. -- Onboarding/Custom providers: raise verification probe token budgets for OpenAI and Anthropic compatibility checks to avoid false negatives on strict provider defaults. (#24743) Thanks @Glucksberg. -- Auth/OAuth: classify missing OAuth scopes as auth failures for clearer remediation and retry behavior. (#24761) -- Providers/OpenRouter: when thinking is explicitly off, avoid injecting `reasoning.effort` so reasoning-required models can use provider defaults instead of failing request validation. (#24863) Thanks @DevSecTim. -- Sessions/Reasoning: persist `reasoningLevel: "off"` explicitly instead of deleting it so session overrides survive patch/update flows. (#24406, #24559) -- Cron/Isolated sessions: use full prompt mode for isolated cron runs so skills/extensions are available during cron execution. (#24944) -- Synology Chat/Webhooks: deregister stale webhook routes before re-registering on channel restart to prevent duplicate route handling. (#24971) -- Plugins/Config: use plugin manifest `id` (instead of npm package name) for config entry keys so plugin settings stay bound correctly. (#24796) -- Plugins/Config schema: support legacy plugin schemas without `toJSONSchema()` by falling back to permissive object schema generation. (#24933) Thanks @pandego. -- Gateway/Prompt builder: safely extract text from mixed content arrays when assembling prompts to avoid malformed prompt payloads. (#24946) -- Gateway/Slug generation: respect agent-level model config in slug generation flows. (#24776) -- Agents/Workspace paths: strip null bytes and guard undefined `.trim()` calls for workspace-path handling to avoid `ENOTDIR`/`TypeError` crashes. (#24876, #24875) -- Agents/Tool warnings: suppress `sessions_send` relay errors from chat-facing warning payloads to avoid leaking transient inter-session transport failures. (#24740) Thanks @Glucksberg. -- Sessions/Model overrides: keep stored sub-agent model overrides when `agents.defaults.models` is empty (allow-any mode) instead of resetting to defaults. (#21088) Thanks @Slats24. -- Subagents/Registry: prune orphaned restored runs (missing child session/sessionId) before retry/announce resume to prevent zombie entries and stale completion retries, and clarify status output to report bootstrap-file presence semantics. (#24244) Thanks @HeMuling. -- Subagents/Announce queue: add exponential backoff when queue-drain delivery fails to reduce retry storms. (#24783) -- Doctor/UX: suppress the redundant "Run doctor --fix" hint when already in fix mode with no changes. (#24666) - CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. -- Doctor/Nix: skip false-positive permission warnings for Nix store symlinks in state-integrity checks. (#24901) -- Update/Systemd: back up an existing systemd unit before overwriting it during update flows. (#24350, #24937) -- Install/Global detection: resolve symlinks when detecting pnpm/bun global install paths. (#24744) -- Infra/Windows TOCTOU: handle Windows `dev=0` edge cases in same-file identity checks. (#24939) -- Exec/Bash tools: clamp poll sleep duration to non-negative values in process polling loops. (#24889) ## 2026.2.23 From 32d7756d8c21abdea4700ec9064bae860e67b5c2 Mon Sep 17 00:00:00 2001 From: DoncicX <348031375@qq.com> Date: Tue, 24 Feb 2026 13:40:35 +0800 Subject: [PATCH 5/8] iOS: extract device/platform info into DeviceInfoHelper, keep Settings platform string as iOS X.Y.Z --- .../ios/Sources/Device/DeviceInfoHelper.swift | 71 +++++++++++++++++++ .../Sources/Device/DeviceStatusService.swift | 17 ++--- .../Gateway/GatewayConnectionController.swift | 46 ++---------- apps/ios/Sources/Settings/SettingsTab.swift | 32 +-------- apps/ios/SwiftSources.input.xcfilelist | 3 + 5 files changed, 87 insertions(+), 82 deletions(-) create mode 100644 apps/ios/Sources/Device/DeviceInfoHelper.swift diff --git a/apps/ios/Sources/Device/DeviceInfoHelper.swift b/apps/ios/Sources/Device/DeviceInfoHelper.swift new file mode 100644 index 00000000000..eeed54c4652 --- /dev/null +++ b/apps/ios/Sources/Device/DeviceInfoHelper.swift @@ -0,0 +1,71 @@ +import Foundation +import UIKit + +import Darwin + +/// Shared device and platform info for Settings, gateway node payloads, and device status. +enum DeviceInfoHelper { + /// e.g. "iOS 18.0.0" or "iPadOS 18.0.0" by interface idiom. Use for gateway/device payloads. + static func platformString() -> String { + let v = ProcessInfo.processInfo.operatingSystemVersion + let name = switch UIDevice.current.userInterfaceIdiom { + case .pad: + "iPadOS" + case .phone: + "iOS" + default: + "iOS" + } + return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + } + + /// Always "iOS X.Y.Z" for UI display (e.g. Settings), matching legacy behavior on iPad. + static func platformStringForDisplay() -> String { + let v = ProcessInfo.processInfo.operatingSystemVersion + return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + } + + /// Device family for display: "iPad", "iPhone", or "iOS". + static func deviceFamily() -> String { + switch UIDevice.current.userInterfaceIdiom { + case .pad: + "iPad" + case .phone: + "iPhone" + default: + "iOS" + } + } + + /// Machine model identifier from uname (e.g. "iPhone17,1"). + static func modelIdentifier() -> String { + var systemInfo = utsname() + uname(&systemInfo) + let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in + String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8) + } + let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? "unknown" : trimmed + } + + /// App marketing version only, e.g. "2026.2.0" or "dev". + static func appVersion() -> String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" + } + + /// App build string, e.g. "123" or "". + static func appBuild() -> String { + let raw = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" + return raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Display string for Settings: "1.2.3" or "1.2.3 (456)" when build differs. + static func openClawVersionString() -> String { + let version = appVersion() + let build = appBuild() + if build.isEmpty || build == version { + return version + } + return "\(version) (\(build))" + } +} diff --git a/apps/ios/Sources/Device/DeviceStatusService.swift b/apps/ios/Sources/Device/DeviceStatusService.swift index fed2716b5b8..a80a98101fa 100644 --- a/apps/ios/Sources/Device/DeviceStatusService.swift +++ b/apps/ios/Sources/Device/DeviceStatusService.swift @@ -26,12 +26,12 @@ final class DeviceStatusService: DeviceStatusServicing { func info() -> OpenClawDeviceInfoPayload { let device = UIDevice.current - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" - let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0" + let appVersion = DeviceInfoHelper.appVersion() + let appBuild = DeviceStatusService.fallbackAppBuild(DeviceInfoHelper.appBuild()) let locale = Locale.preferredLanguages.first ?? Locale.current.identifier return OpenClawDeviceInfoPayload( deviceName: device.name, - modelIdentifier: Self.modelIdentifier(), + modelIdentifier: DeviceInfoHelper.modelIdentifier(), systemName: device.systemName, systemVersion: device.systemVersion, appVersion: appVersion, @@ -75,13 +75,8 @@ final class DeviceStatusService: DeviceStatusServicing { return OpenClawStorageStatusPayload(totalBytes: total, freeBytes: free, usedBytes: used) } - private static func modelIdentifier() -> String { - var systemInfo = utsname() - uname(&systemInfo) - let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in - String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8) - } - let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? "unknown" : trimmed + /// Fallback for payloads that require a non-empty build (e.g. "0"). + private static func fallbackAppBuild(_ build: String) -> String { + build.isEmpty ? "0" : build } } diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 2b7f94ba453..a770fcb2c6f 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -921,44 +921,6 @@ final class GatewayConnectionController { private static func motionAvailable() -> Bool { CMMotionActivityManager.isActivityAvailable() || CMPedometer.isStepCountingAvailable() } - - private func platformString() -> String { - let v = ProcessInfo.processInfo.operatingSystemVersion - let name = switch UIDevice.current.userInterfaceIdiom { - case .pad: - "iPadOS" - case .phone: - "iOS" - default: - "iOS" - } - return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" - } - - private func deviceFamily() -> String { - switch UIDevice.current.userInterfaceIdiom { - case .pad: - "iPad" - case .phone: - "iPhone" - default: - "iOS" - } - } - - private func modelIdentifier() -> String { - var systemInfo = utsname() - uname(&systemInfo) - let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in - String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8) - } - let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? "unknown" : trimmed - } - - private func appVersion() -> String { - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" - } } #if DEBUG @@ -980,19 +942,19 @@ extension GatewayConnectionController { } func _test_platformString() -> String { - self.platformString() + DeviceInfoHelper.platformString() } func _test_deviceFamily() -> String { - self.deviceFamily() + DeviceInfoHelper.deviceFamily() } func _test_modelIdentifier() -> String { - self.modelIdentifier() + DeviceInfoHelper.modelIdentifier() } func _test_appVersion() -> String { - self.appVersion() + DeviceInfoHelper.appVersion() } func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) { diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 024a4cbf42b..3ff2ed465c3 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -374,9 +374,9 @@ struct SettingsTab: View { .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) - LabeledContent("Device", value: self.deviceFamily()) - LabeledContent("Platform", value: self.platformString()) - LabeledContent("OpenClaw", value: self.openClawVersionString()) + LabeledContent("Device", value: DeviceInfoHelper.deviceFamily()) + LabeledContent("Platform", value: DeviceInfoHelper.platformStringForDisplay()) + LabeledContent("OpenClaw", value: DeviceInfoHelper.openClawVersionString()) } } } @@ -584,32 +584,6 @@ struct SettingsTab: View { return trimmed.isEmpty ? "Not connected" : trimmed } - private func platformString() -> String { - let v = ProcessInfo.processInfo.operatingSystemVersion - return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" - } - - private func deviceFamily() -> String { - switch UIDevice.current.userInterfaceIdiom { - case .pad: - "iPad" - case .phone: - "iPhone" - default: - "iOS" - } - } - - private func openClawVersionString() -> String { - let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" - let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" - let trimmedBuild = build.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedBuild.isEmpty || trimmedBuild == version { - return version - } - return "\(version) (\(trimmedBuild))" - } - private func featureToggle( _ title: String, isOn: Binding, diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist index 5b1ba7d70e6..514ca732673 100644 --- a/apps/ios/SwiftSources.input.xcfilelist +++ b/apps/ios/SwiftSources.input.xcfilelist @@ -4,6 +4,9 @@ Sources/Gateway/GatewayDiscoveryModel.swift Sources/Gateway/GatewaySettingsStore.swift Sources/Gateway/KeychainStore.swift Sources/Camera/CameraController.swift +Sources/Device/DeviceInfoHelper.swift +Sources/Device/DeviceStatusService.swift +Sources/Device/NetworkStatusService.swift Sources/Chat/ChatSheet.swift Sources/Chat/IOSGatewayChatTransport.swift Sources/OpenClawApp.swift From 4d124e4a9b755d012eb81c9a44746eaaedacc9c1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:03:04 +0000 Subject: [PATCH 6/8] feat(security): warn on likely multi-user trust-model mismatch --- CHANGELOG.md | 1 + docs/cli/security.md | 2 + docs/gateway/security/index.md | 16 +++ src/security/audit-extra.sync.ts | 215 ++++++++++++++++++++++++------- src/security/audit-extra.ts | 1 + src/security/audit.test.ts | 47 +++++++ src/security/audit.ts | 2 + 7 files changed, 236 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce418f75452..f21e779885a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants), accept trailing punctuation (for example `STOP OPENCLAW!!!`), and add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms) so emergency stop messages are caught more reliably. (#25103) Thanks @steipete and @vincentkoc. +- Security/Audit: add `security.trust_model.multi_user_heuristic` to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (`sandbox.mode="all"`, workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes). ### Fixes diff --git a/docs/cli/security.md b/docs/cli/security.md index 9b1cce7db79..6f9be145a68 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -25,6 +25,8 @@ openclaw security audit --json The audit warns when multiple DM senders share the main session and recommends **secure DM mode**: `session.dmScope="per-channel-peer"` (or `per-account-channel-peer` for multi-account channels) for shared inboxes. This is for cooperative/shared inbox hardening. A single Gateway shared by mutually untrusted/adversarial operators is not a recommended setup; split trust boundaries with separate gateways (or separate OS users/hosts). +It also emits `security.trust_model.multi_user_heuristic` when config suggests likely shared-user ingress (for example configured group targets or wildcard sender rules), and reminds you that OpenClaw is a personal-assistant trust model by default. +For intentional shared-user setups, the audit guidance is to sandbox all sessions, keep filesystem access workspace-scoped, and keep personal/private identities or credentials off that runtime. It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled. For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`. It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed extension plugin tools may be reachable under permissive tool policy. diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 49b985be2a6..613866bd959 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -7,6 +7,22 @@ title: "Security" # Security 🔒 +> [!WARNING] +> **Personal assistant trust model:** this guidance assumes one trusted operator boundary per gateway (single-user/personal assistant model). +> OpenClaw is **not** a hostile multi-tenant security boundary for multiple adversarial users sharing one agent/gateway. +> If you need mixed-trust or adversarial-user operation, split trust boundaries (separate gateway + credentials, ideally separate OS users/hosts). + +## Scope first: personal assistant security model + +OpenClaw security guidance assumes a **personal assistant** deployment: one trusted operator boundary, potentially many agents. + +- Supported security posture: one user/trust boundary per gateway (prefer one OS user/host/VPS per boundary). +- Not a supported security boundary: one shared gateway/agent used by mutually untrusted or adversarial users. +- If adversarial-user isolation is required, split by trust boundary (separate gateway + credentials, and ideally separate OS users/hosts). +- If multiple untrusted users can message one tool-enabled agent, treat them as sharing the same delegated tool authority for that agent. + +This page explains hardening **within that model**. It does not claim hostile multi-tenant isolation on one shared gateway. + ## Quick check: `openclaw security audit` See also: [Formal Verification (Security Models)](/security/formal-verification/) diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index e5417a0f9be..464930d9126 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -338,6 +338,137 @@ function listGroupPolicyOpen(cfg: OpenClawConfig): string[] { return out; } +function hasConfiguredGroupTargets(section: Record): boolean { + const groupKeys = ["groups", "guilds", "channels", "rooms"]; + return groupKeys.some((key) => { + const value = section[key]; + return Boolean(value && typeof value === "object" && Object.keys(value).length > 0); + }); +} + +function listPotentialMultiUserSignals(cfg: OpenClawConfig): string[] { + const out = new Set(); + const channels = cfg.channels as Record | undefined; + if (!channels || typeof channels !== "object") { + return []; + } + + const inspectSection = (section: Record, basePath: string) => { + const groupPolicy = typeof section.groupPolicy === "string" ? section.groupPolicy : null; + if (groupPolicy === "open") { + out.add(`${basePath}.groupPolicy="open"`); + } else if (groupPolicy === "allowlist" && hasConfiguredGroupTargets(section)) { + out.add(`${basePath}.groupPolicy="allowlist" with configured group targets`); + } + + const dmPolicy = typeof section.dmPolicy === "string" ? section.dmPolicy : null; + if (dmPolicy === "open") { + out.add(`${basePath}.dmPolicy="open"`); + } + + const allowFrom = Array.isArray(section.allowFrom) ? section.allowFrom : []; + if (allowFrom.some((entry) => String(entry).trim() === "*")) { + out.add(`${basePath}.allowFrom includes "*"`); + } + + const groupAllowFrom = Array.isArray(section.groupAllowFrom) ? section.groupAllowFrom : []; + if (groupAllowFrom.some((entry) => String(entry).trim() === "*")) { + out.add(`${basePath}.groupAllowFrom includes "*"`); + } + + const dm = section.dm; + if (dm && typeof dm === "object") { + const dmSection = dm as Record; + const dmLegacyPolicy = typeof dmSection.policy === "string" ? dmSection.policy : null; + if (dmLegacyPolicy === "open") { + out.add(`${basePath}.dm.policy="open"`); + } + const dmAllowFrom = Array.isArray(dmSection.allowFrom) ? dmSection.allowFrom : []; + if (dmAllowFrom.some((entry) => String(entry).trim() === "*")) { + out.add(`${basePath}.dm.allowFrom includes "*"`); + } + } + }; + + for (const [channelId, value] of Object.entries(channels)) { + if (!value || typeof value !== "object") { + continue; + } + const section = value as Record; + inspectSection(section, `channels.${channelId}`); + const accounts = section.accounts; + if (!accounts || typeof accounts !== "object") { + continue; + } + for (const [accountId, accountValue] of Object.entries(accounts)) { + if (!accountValue || typeof accountValue !== "object") { + continue; + } + inspectSection( + accountValue as Record, + `channels.${channelId}.accounts.${accountId}`, + ); + } + } + + return Array.from(out); +} + +function collectRiskyToolExposureContexts(cfg: OpenClawConfig): { + riskyContexts: string[]; + hasRuntimeRisk: boolean; +} { + const contexts: Array<{ + label: string; + agentId?: string; + tools?: AgentToolsConfig; + }> = [{ label: "agents.defaults" }]; + for (const agent of cfg.agents?.list ?? []) { + if (!agent || typeof agent !== "object" || typeof agent.id !== "string") { + continue; + } + contexts.push({ + label: `agents.list.${agent.id}`, + agentId: agent.id, + tools: agent.tools, + }); + } + + const riskyContexts: string[] = []; + let hasRuntimeRisk = false; + for (const context of contexts) { + const sandboxMode = resolveSandboxConfigForAgent(cfg, context.agentId).mode; + const policies = resolveToolPolicies({ + cfg, + agentTools: context.tools, + sandboxMode, + agentId: context.agentId ?? null, + }); + const runtimeTools = ["exec", "process"].filter((tool) => + isToolAllowedByPolicies(tool, policies), + ); + const fsTools = ["read", "write", "edit", "apply_patch"].filter((tool) => + isToolAllowedByPolicies(tool, policies), + ); + const fsWorkspaceOnly = context.tools?.fs?.workspaceOnly ?? cfg.tools?.fs?.workspaceOnly; + const runtimeUnguarded = runtimeTools.length > 0 && sandboxMode !== "all"; + const fsUnguarded = fsTools.length > 0 && sandboxMode !== "all" && fsWorkspaceOnly !== true; + if (!runtimeUnguarded && !fsUnguarded) { + continue; + } + if (runtimeUnguarded) { + hasRuntimeRisk = true; + } + riskyContexts.push( + `${context.label} (sandbox=${sandboxMode}; runtime=[${runtimeTools.join(", ") || "off"}]; fs=[${fsTools.join(", ") || "off"}]; fs.workspaceOnly=${ + fsWorkspaceOnly === true ? "true" : "false" + })`, + ); + } + + return { riskyContexts, hasRuntimeRisk }; +} + // -------------------------------------------------------------------------- // Exported collectors // -------------------------------------------------------------------------- @@ -358,7 +489,9 @@ export function collectAttackSurfaceSummaryFindings(cfg: OpenClawConfig): Securi `\n` + `hooks.internal: ${internalHooksEnabled ? "enabled" : "disabled"}` + `\n` + - `browser control: ${browserEnabled ? "enabled" : "disabled"}`; + `browser control: ${browserEnabled ? "enabled" : "disabled"}` + + `\n` + + "trust model: personal assistant (one trusted operator boundary), not hostile multi-tenant on one shared gateway"; return [ { @@ -1096,53 +1229,7 @@ export function collectExposureMatrixFindings(cfg: OpenClawConfig): SecurityAudi }); } - const contexts: Array<{ - label: string; - agentId?: string; - tools?: AgentToolsConfig; - }> = [{ label: "agents.defaults" }]; - for (const agent of cfg.agents?.list ?? []) { - if (!agent || typeof agent !== "object" || typeof agent.id !== "string") { - continue; - } - contexts.push({ - label: `agents.list.${agent.id}`, - agentId: agent.id, - tools: agent.tools, - }); - } - - const riskyContexts: string[] = []; - let hasRuntimeRisk = false; - for (const context of contexts) { - const sandboxMode = resolveSandboxConfigForAgent(cfg, context.agentId).mode; - const policies = resolveToolPolicies({ - cfg, - agentTools: context.tools, - sandboxMode, - agentId: context.agentId ?? null, - }); - const runtimeTools = ["exec", "process"].filter((tool) => - isToolAllowedByPolicies(tool, policies), - ); - const fsTools = ["read", "write", "edit", "apply_patch"].filter((tool) => - isToolAllowedByPolicies(tool, policies), - ); - const fsWorkspaceOnly = context.tools?.fs?.workspaceOnly ?? cfg.tools?.fs?.workspaceOnly; - const runtimeUnguarded = runtimeTools.length > 0 && sandboxMode !== "all"; - const fsUnguarded = fsTools.length > 0 && sandboxMode !== "all" && fsWorkspaceOnly !== true; - if (!runtimeUnguarded && !fsUnguarded) { - continue; - } - if (runtimeUnguarded) { - hasRuntimeRisk = true; - } - riskyContexts.push( - `${context.label} (sandbox=${sandboxMode}; runtime=[${runtimeTools.join(", ") || "off"}]; fs=[${fsTools.join(", ") || "off"}]; fs.workspaceOnly=${ - fsWorkspaceOnly === true ? "true" : "false" - })`, - ); - } + const { riskyContexts, hasRuntimeRisk } = collectRiskyToolExposureContexts(cfg); if (riskyContexts.length > 0) { findings.push({ @@ -1160,3 +1247,35 @@ export function collectExposureMatrixFindings(cfg: OpenClawConfig): SecurityAudi return findings; } + +export function collectLikelyMultiUserSetupFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { + const findings: SecurityAuditFinding[] = []; + const signals = listPotentialMultiUserSignals(cfg); + if (signals.length === 0) { + return findings; + } + + const { riskyContexts, hasRuntimeRisk } = collectRiskyToolExposureContexts(cfg); + const impactLine = hasRuntimeRisk + ? "Runtime/process tools are exposed without full sandboxing in at least one context." + : "No unguarded runtime/process tools were detected by this heuristic."; + const riskyContextsDetail = + riskyContexts.length > 0 + ? `Potential high-impact tool exposure contexts:\n${riskyContexts.map((line) => `- ${line}`).join("\n")}` + : "No unguarded runtime/filesystem contexts detected."; + + findings.push({ + checkId: "security.trust_model.multi_user_heuristic", + severity: "warn", + title: "Potential multi-user setup detected (personal-assistant model warning)", + detail: + "Heuristic signals indicate this gateway may be reachable by multiple users:\n" + + signals.map((signal) => `- ${signal}`).join("\n") + + `\n${impactLine}\n${riskyContextsDetail}\n` + + "OpenClaw's default security model is personal-assistant (one trusted operator boundary), not hostile multi-tenant isolation on one shared gateway.", + remediation: + 'If users may be mutually untrusted, split trust boundaries (separate gateways + credentials, ideally separate OS users/hosts). If you intentionally run shared-user access, set agents.defaults.sandbox.mode="all", keep tools.fs.workspaceOnly=true, deny runtime/fs/web tools unless required, and keep personal/private identities + credentials off that runtime.', + }); + + return findings; +} diff --git a/src/security/audit-extra.ts b/src/security/audit-extra.ts index fa2b82fa150..9345cb8732b 100644 --- a/src/security/audit-extra.ts +++ b/src/security/audit-extra.ts @@ -14,6 +14,7 @@ export { collectGatewayHttpNoAuthFindings, collectGatewayHttpSessionKeyOverrideFindings, collectHooksHardeningFindings, + collectLikelyMultiUserSetupFindings, collectMinimalProfileOverrideFindings, collectModelHygieneFindings, collectNodeDangerousAllowCommandFindings, diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 2b4fbebe033..3b7d54fcb8d 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -178,12 +178,14 @@ describe("security audit", () => { }; const res = await audit(cfg); + const summary = res.findings.find((f) => f.checkId === "summary.attack_surface"); expect(res.findings).toEqual( expect.arrayContaining([ expect.objectContaining({ checkId: "summary.attack_surface", severity: "info" }), ]), ); + expect(summary?.detail).toContain("trust model: personal assistant"); }); it("flags non-loopback bind without auth as critical", async () => { @@ -2696,6 +2698,51 @@ description: test skill ).toBe(false); }); + it("warns when config heuristics suggest a likely multi-user setup", async () => { + const cfg: OpenClawConfig = { + channels: { + discord: { + groupPolicy: "allowlist", + guilds: { + "1234567890": { + channels: { + "7777777777": { allow: true }, + }, + }, + }, + }, + }, + tools: { elevated: { enabled: false } }, + }; + + const res = await audit(cfg); + const finding = res.findings.find( + (f) => f.checkId === "security.trust_model.multi_user_heuristic", + ); + + expect(finding?.severity).toBe("warn"); + expect(finding?.detail).toContain( + 'channels.discord.groupPolicy="allowlist" with configured group targets', + ); + expect(finding?.detail).toContain("personal-assistant"); + expect(finding?.remediation).toContain('agents.defaults.sandbox.mode="all"'); + }); + + it("does not warn for multi-user heuristic when no shared-user signals are configured", async () => { + const cfg: OpenClawConfig = { + channels: { + discord: { + groupPolicy: "allowlist", + }, + }, + tools: { elevated: { enabled: false } }, + }; + + const res = await audit(cfg); + + expectNoFinding(res, "security.trust_model.multi_user_heuristic"); + }); + describe("maybeProbeGateway auth selection", () => { const makeProbeCapture = () => { let capturedAuth: { token?: string; password?: string } | undefined; diff --git a/src/security/audit.ts b/src/security/audit.ts index 6d4aa90d380..c1714ca4969 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -24,6 +24,7 @@ import { collectHooksHardeningFindings, collectIncludeFilePermFindings, collectInstalledSkillsCodeSafetyFindings, + collectLikelyMultiUserSetupFindings, collectSandboxBrowserHashLabelFindings, collectMinimalProfileOverrideFindings, collectModelHygieneFindings, @@ -866,6 +867,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise Date: Tue, 24 Feb 2026 21:13:34 +0800 Subject: [PATCH 7/8] fix(agents): await block-reply flush before tool execution starts handleToolExecutionStart() flushed pending block replies and then called onBlockReplyFlush() as fire-and-forget (`void`). This created a race where fast tool results (especially media on Telegram) could be delivered before the text block that preceded the tool call. Await onBlockReplyFlush() so the block pipeline finishes before tool execution continues, preserving delivery order. Fixes #25267 Co-authored-by: Cursor --- ...-embedded-subscribe.handlers.tools.test.ts | 31 +++++++++++++++++++ .../pi-embedded-subscribe.handlers.tools.ts | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts index c03eb00da57..96a988e5bc6 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts @@ -88,6 +88,37 @@ describe("handleToolExecutionStart read path checks", () => { expect(warn).toHaveBeenCalledTimes(1); expect(String(warn.mock.calls[0]?.[0] ?? "")).toContain("read tool called without path"); }); + + it("awaits onBlockReplyFlush before continuing tool start processing", async () => { + const { ctx, onBlockReplyFlush } = createTestContext(); + let releaseFlush: (() => void) | undefined; + onBlockReplyFlush.mockImplementation( + () => + new Promise((resolve) => { + releaseFlush = resolve; + }), + ); + + const evt: ToolExecutionStartEvent = { + type: "tool_execution_start", + toolName: "exec", + toolCallId: "tool-await-flush", + args: { command: "echo hi" }, + }; + + const pending = handleToolExecutionStart(ctx, evt); + // Let the async function reach the awaited flush Promise. + await Promise.resolve(); + + // If flush isn't awaited, tool metadata would already be recorded here. + expect(ctx.state.toolMetaById.has("tool-await-flush")).toBe(false); + expect(releaseFlush).toBeTypeOf("function"); + + releaseFlush?.(); + await pending; + + expect(ctx.state.toolMetaById.has("tool-await-flush")).toBe(true); + }); }); describe("handleToolExecutionEnd cron.add commitment tracking", () => { diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.ts b/src/agents/pi-embedded-subscribe.handlers.tools.ts index ea3031a6cc4..18dc11193f0 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.ts @@ -174,7 +174,7 @@ export async function handleToolExecutionStart( // Flush pending block replies to preserve message boundaries before tool execution. ctx.flushBlockReplyBuffer(); if (ctx.params.onBlockReplyFlush) { - void ctx.params.onBlockReplyFlush(); + await ctx.params.onBlockReplyFlush(); } const rawToolName = String(evt.toolName); From d84659f22fc59d9eecfa6f1cebe24b79674bed5a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 14:11:18 +0000 Subject: [PATCH 8/8] fix: add changelog for block-reply flush await (#25427) (thanks @SidQin-cyber) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f21e779885a..e53ecd00ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber. - macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. - Control UI/Chat images: harden image-open clicks against reverse tabnabbing by using opener isolation (`noopener,noreferrer` plus `window.opener = null`). (#18685) Thanks @Mariana-Codebase. - CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18.