diff --git a/CHANGELOG.md b/CHANGELOG.md index dd664c4dadc..2f86ca22e02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -213,6 +213,7 @@ Docs: https://docs.openclaw.ai - Gateway/probes: keep `/health`, `/healthz`, `/ready`, and `/readyz` reachable when the Control UI is mounted at `/`, preserve plugin-owned route precedence on those paths, and make `/ready` and `/readyz` report channel-backed readiness with startup grace plus `503` on disconnected managed channels, while `/health` and `/healthz` stay shallow liveness probes. (#18446) Thanks @vibecodooor, @mahsumaktas, and @vincentkoc. - Feishu/media downloads: drop invalid timeout fields from SDK method calls now that client-level `httpTimeoutMs` applies to requests. (#38267) Thanks @ant1eicher and @thewilloftheshadow. - PI embedded runner/Feishu docs: propagate sender identity into embedded attempts so Feishu doc auto-grant restores requester access for embedded-runner executions. (#32915) thanks @cszhouwei. +- Auth/owner-only tool gating: preserve internal `operator.admin` owner authorization and limit the no-owner-allowlist fallback to direct chats so ownerOnly tools stay available for safe single-user setups without granting group chats implicit owner access. (#26331) thanks @widingmarcus-cyber. ## 2026.3.2 @@ -573,6 +574,7 @@ Docs: https://docs.openclaw.ai - Logging/Subsystem console timestamps: route subsystem console timestamp rendering through `formatConsoleTimestamp(...)` so `pretty` and timestamp-prefix output use local timezone formatting consistently instead of inline UTC `toISOString()` paths. (#25970) Thanks @openperf. - Feishu/Multi-account + reply reliability: add `channels.feishu.defaultAccount` outbound routing support with schema validation, prevent inbound preview text from leaking into prompt system events, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as `msg_type: "file"`, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #31209, #29610, #30432, #30331, and #29501. Thanks @stakeswky, @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff. - Feishu/Target routing + replies + dedupe: normalize provider-prefixed targets (`feishu:`/`lark:`), prefer configured `channels.feishu.defaultAccount` for tool execution, honor Feishu outbound `renderMode` in adapter text/caption sends, fall back to normal send when reply targets are withdrawn/deleted, and add synchronous in-memory dedupe guard for concurrent duplicate inbound events. Landed from contributor PRs #30428, #30438, #29958, #30444, and #29463. Thanks @bmendonca3 and @Yaxuan42. +- Commands/Owner-only tools: treat identified direct-chat senders as owners when no owner allowlist is configured, while preserving internal `operator.admin` owner sessions. (#26331) thanks @widingmarcus-cyber - Channels/Multi-account default routing: add optional `channels..defaultAccount` default-selection support across message channels so omitted `accountId` routes to an explicit configured account instead of relying on implicit first-entry ordering (fallback behavior unchanged when unset). - Google Chat/Thread replies: set `messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD` on threaded sends so replies attach to existing threads instead of silently failing thread placement. Landed from contributor PR #30965 by @novan. Thanks @novan. - Mattermost/Private channel policy routing: map Mattermost private channel type `P` to group chat type so `groupPolicy`/`groupAllowFrom` gates apply correctly instead of being treated as open public channels. Landed from contributor PR #30891 by @BlueBirdBack. Thanks @BlueBirdBack. diff --git a/src/auto-reply/command-auth.owner-default.test.ts b/src/auto-reply/command-auth.owner-default.test.ts new file mode 100644 index 00000000000..117370192d8 --- /dev/null +++ b/src/auto-reply/command-auth.owner-default.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; +import { resolveCommandAuthorization } from "./command-auth.js"; +import type { MsgContext } from "./templating.js"; + +const createRegistry = () => + createTestRegistry([ + { + pluginId: "discord", + plugin: createOutboundTestPlugin({ id: "discord", outbound: { deliveryMode: "direct" } }), + source: "test", + }, + ]); + +beforeEach(() => { + setActivePluginRegistry(createRegistry()); +}); + +afterEach(() => { + setActivePluginRegistry(createRegistry()); +}); + +describe("senderIsOwner defaults to true when no owner allowlist configured (#26319)", () => { + it("senderIsOwner is true when no ownerAllowFrom is configured (single-user default)", () => { + const cfg = { + channels: { discord: {} }, + } as OpenClawConfig; + + const ctx = { + Provider: "discord", + Surface: "discord", + ChatType: "direct", + From: "discord:123", + SenderId: "123", + } as MsgContext; + + const auth = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized: true, + }); + + // Without an explicit ownerAllowFrom list, the sole authorized user should + // be treated as owner so ownerOnly tools (cron, gateway) are available. + expect(auth.senderIsOwner).toBe(true); + }); + + it("senderIsOwner is false when no ownerAllowFrom is configured in a group chat", () => { + const cfg = { + channels: { discord: {} }, + } as OpenClawConfig; + + const ctx = { + Provider: "discord", + Surface: "discord", + ChatType: "group", + From: "discord:123", + SenderId: "123", + } as MsgContext; + + const auth = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized: true, + }); + + expect(auth.senderIsOwner).toBe(false); + }); + + it("senderIsOwner is false when ownerAllowFrom is configured and sender does not match", () => { + const cfg = { + channels: { discord: {} }, + commands: { ownerAllowFrom: ["456"] }, + } as OpenClawConfig; + + const ctx = { + Provider: "discord", + Surface: "discord", + From: "discord:789", + SenderId: "789", + } as MsgContext; + + const auth = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized: true, + }); + + expect(auth.senderIsOwner).toBe(false); + }); + + it("senderIsOwner is true when ownerAllowFrom matches sender", () => { + const cfg = { + channels: { discord: {} }, + commands: { ownerAllowFrom: ["456"] }, + } as OpenClawConfig; + + const ctx = { + Provider: "discord", + Surface: "discord", + From: "discord:456", + SenderId: "456", + } as MsgContext; + + const auth = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized: true, + }); + + expect(auth.senderIsOwner).toBe(true); + }); + + it("senderIsOwner is true when ownerAllowFrom is wildcard (*)", () => { + const cfg = { + channels: { discord: {} }, + commands: { ownerAllowFrom: ["*"] }, + } as OpenClawConfig; + + const ctx = { + Provider: "discord", + Surface: "discord", + From: "discord:anyone", + SenderId: "anyone", + } as MsgContext; + + const auth = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized: true, + }); + + expect(auth.senderIsOwner).toBe(true); + }); + + it("senderIsOwner is true for internal operator.admin sessions", () => { + const cfg = {} as OpenClawConfig; + + const ctx = { + Provider: "webchat", + Surface: "webchat", + GatewayClientScopes: ["operator.admin"], + } as MsgContext; + + const auth = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized: true, + }); + + expect(auth.senderIsOwner).toBe(true); + }); +}); diff --git a/src/auto-reply/command-auth.ts b/src/auto-reply/command-auth.ts index ed37427d50b..87f64e8c8f1 100644 --- a/src/auto-reply/command-auth.ts +++ b/src/auto-reply/command-auth.ts @@ -350,8 +350,15 @@ export function resolveCommandAuthorization(params: { isInternalMessageChannel(ctx.Provider) && Array.isArray(ctx.GatewayClientScopes) && ctx.GatewayClientScopes.includes("operator.admin"); - const senderIsOwner = senderIsOwnerByIdentity || senderIsOwnerByScope; const ownerAllowlistConfigured = ownerAllowAll || explicitOwners.length > 0; + const isDirectChat = (ctx.ChatType ?? "").trim().toLowerCase() === "direct"; + // In the default single-user direct-chat setup, allow an identified sender to + // keep ownerOnly tools even without an explicit owner allowlist. + const senderIsOwner = + senderIsOwnerByIdentity || + senderIsOwnerByScope || + ownerAllowAll || + (!ownerAllowlistConfigured && isDirectChat && Boolean(senderId)); const requireOwner = enforceOwner || ownerAllowlistConfigured; const isOwnerForCommands = !requireOwner ? true