From ea75cd897182bef44da045be3ab51913ccfa4714 Mon Sep 17 00:00:00 2001 From: Pavan Kumar Gondhi Date: Mon, 4 May 2026 22:47:19 +0530 Subject: [PATCH] Gate zalouser startup name matching [AI] (#77411) * fix: gate zalouser startup name matching * addressing codex review * docs: add changelog entry for PR merge --- CHANGELOG.md | 1 + docs/channels/zalouser.md | 12 ++-- .../zalouser/src/monitor.group-gating.test.ts | 69 ++++++++++++++++++- extensions/zalouser/src/monitor.ts | 5 +- 4 files changed, 79 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80f9d7bbf3c..8c328827d6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gate zalouser startup name matching [AI]. (#77411) Thanks @pgondhi987. - fix(device-pair): require pairing scope for pair command [AI]. (#76377) Thanks @pgondhi987. - fix(qqbot): keep private commands off framework surface [AI]. (#77212) Thanks @pgondhi987. - Memory/wiki: preserve representation from both corpora in `corpus=all` searches while backfilling unused result capacity, so memory hits are not starved by numerically higher wiki integer scores. Fixes #77337. Thanks @hclsys. diff --git a/docs/channels/zalouser.md b/docs/channels/zalouser.md index beaf240fb90..45c4bafd3bb 100644 --- a/docs/channels/zalouser.md +++ b/docs/channels/zalouser.md @@ -81,7 +81,9 @@ openclaw directory groups list --channel zalouser --query "work" `channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`). -`channels.zalouser.allowFrom` accepts user IDs or names. During setup, names are resolved to IDs using the plugin's in-process contact lookup. +`channels.zalouser.allowFrom` should use stable Zalo user IDs. During interactive setup, entered names can be resolved to IDs using the plugin's in-process contact lookup. + +If a raw name remains in config, startup resolves it only when `channels.zalouser.dangerouslyAllowNameMatching: true` is enabled. Without that opt-in, runtime sender checks are ID-only and raw names are ignored for authorization. Approve via: @@ -93,13 +95,13 @@ Approve via: - Default: `channels.zalouser.groupPolicy = "open"` (groups allowed). Use `channels.defaults.groupPolicy` to override the default when unset. - Restrict to an allowlist with: - `channels.zalouser.groupPolicy = "allowlist"` - - `channels.zalouser.groups` (keys should be stable group IDs; names are resolved to IDs on startup when possible) + - `channels.zalouser.groups` (keys should be stable group IDs; names are resolved to IDs on startup only when `channels.zalouser.dangerouslyAllowNameMatching: true` is enabled) - `channels.zalouser.groupAllowFrom` (controls which senders in allowed groups can trigger the bot) - Block all groups: `channels.zalouser.groupPolicy = "disabled"`. - The configure wizard can prompt for group allowlists. -- On startup, OpenClaw resolves group/user names in allowlists to IDs and logs the mapping. +- On startup, OpenClaw resolves group/user names in allowlists to IDs and logs the mapping only when `channels.zalouser.dangerouslyAllowNameMatching: true` is enabled. - Group allowlist matching is ID-only by default. Unresolved names are ignored for auth unless `channels.zalouser.dangerouslyAllowNameMatching: true` is enabled. -- `channels.zalouser.dangerouslyAllowNameMatching: true` is a break-glass compatibility mode that re-enables mutable group-name matching. +- `channels.zalouser.dangerouslyAllowNameMatching: true` is a break-glass compatibility mode that re-enables mutable startup name resolution and runtime group-name matching. - If `groupAllowFrom` is unset, runtime falls back to `allowFrom` for group sender checks. - Sender checks apply to both normal group messages and control commands (for example `/new`, `/reset`). @@ -181,7 +183,7 @@ Accounts map to `zalouser` profiles in OpenClaw state. Example: **Allowlist/group name didn't resolve:** -- Use numeric IDs in `allowFrom`/`groupAllowFrom`/`groups`, or exact friend/group names. +- Use numeric IDs in `allowFrom`/`groupAllowFrom` and stable group IDs in `groups`. If you intentionally need exact friend/group names, enable `channels.zalouser.dangerouslyAllowNameMatching: true`. **Upgraded from old CLI-based setup:** diff --git a/extensions/zalouser/src/monitor.group-gating.test.ts b/extensions/zalouser/src/monitor.group-gating.test.ts index 4be8361ffb5..a6a878b3df9 100644 --- a/extensions/zalouser/src/monitor.group-gating.test.ts +++ b/extensions/zalouser/src/monitor.group-gating.test.ts @@ -3,7 +3,7 @@ import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js"; import "./monitor.send-mocks.js"; import "./zalo-js.test-mocks.js"; import { resolveZalouserAccountSync } from "./accounts.js"; -import { __testing } from "./monitor.js"; +import { __testing, monitorZalouserProvider } from "./monitor.js"; import { sendDeliveredZalouserMock, sendMessageZalouserMock, @@ -13,6 +13,11 @@ import { import { setZalouserRuntime } from "./runtime.js"; import { createZalouserRuntimeEnv } from "./test-helpers.js"; import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js"; +import { + listZaloFriendsMock, + listZaloGroupsMock, + startZaloListenerMock, +} from "./zalo-js.test-mocks.js"; function createAccount(): ResolvedZalouserAccount { return { @@ -341,6 +346,12 @@ describe("zalouser monitor group mention gating", () => { sendTypingZalouserMock.mockClear(); sendDeliveredZalouserMock.mockClear(); sendSeenZalouserMock.mockClear(); + listZaloFriendsMock.mockReset(); + listZaloFriendsMock.mockResolvedValue([]); + listZaloGroupsMock.mockReset(); + listZaloGroupsMock.mockResolvedValue([]); + startZaloListenerMock.mockReset(); + startZaloListenerMock.mockResolvedValue({ stop: vi.fn() }); }); async function processMessageWithDefaults(params: { @@ -374,6 +385,23 @@ describe("zalouser monitor group mention gating", () => { expect(sendTypingZalouserMock).not.toHaveBeenCalled(); } + async function startMonitorForStartupResolution( + accountConfig: ResolvedZalouserAccount["config"], + ) { + installRuntime({ commandAuthorized: false }); + const abortController = new AbortController(); + abortController.abort(); + await monitorZalouserProvider({ + account: { + ...createAccount(), + config: accountConfig, + }, + config: createConfig(), + runtime: createRuntimeEnv(), + abortSignal: abortController.signal, + }); + } + async function expectGroupCommandAuthorizers(params: { accountConfig: ResolvedZalouserAccount["config"]; expectedAuthorizers: Array<{ configured: boolean; allowed: boolean }>; @@ -669,6 +697,45 @@ describe("zalouser monitor group mention gating", () => { expect(callArg?.ctx?.To).toBe("zalouser:group:g-attacker-001"); }); + it("does not resolve mutable allowlist or group names at startup by default", async () => { + listZaloFriendsMock.mockResolvedValue([{ userId: "999", displayName: "Alice" }]); + listZaloGroupsMock.mockResolvedValue([{ groupId: "g-other", name: "Trusted Team" }]); + + await startMonitorForStartupResolution({ + ...createAccount().config, + dmPolicy: "allowlist", + allowFrom: ["Alice"], + groupPolicy: "allowlist", + groupAllowFrom: ["Alice"], + groups: { + "Trusted Team": { enabled: true }, + }, + }); + + expect(listZaloFriendsMock).not.toHaveBeenCalled(); + expect(listZaloGroupsMock).not.toHaveBeenCalled(); + }); + + it("resolves mutable allowlist and group names at startup when enabled", async () => { + listZaloFriendsMock.mockResolvedValue([{ userId: "123", displayName: "Alice" }]); + listZaloGroupsMock.mockResolvedValue([{ groupId: "g-trusted", name: "Trusted Team" }]); + + await startMonitorForStartupResolution({ + ...createAccount().config, + dangerouslyAllowNameMatching: true, + dmPolicy: "allowlist", + allowFrom: ["Alice"], + groupPolicy: "allowlist", + groupAllowFrom: ["Alice"], + groups: { + "Trusted Team": { enabled: true }, + }, + }); + + expect(listZaloFriendsMock).toHaveBeenCalledWith("default"); + expect(listZaloGroupsMock).toHaveBeenCalledWith("default"); + }); + it("allows group control commands when sender is in groupAllowFrom", async () => { await expectGroupCommandAuthorizers({ accountConfig: { diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index af4770168b8..cf42a67e9fb 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -828,8 +828,9 @@ export async function monitorZalouserProvider( const groupAllowFromEntries = (account.config.groupAllowFrom ?? []) .map((entry) => normalizeZalouserEntry(String(entry))) .filter((entry) => entry && entry !== "*"); + const allowNameMatching = isDangerousNameMatchingEnabled(account.config); - if (allowFromEntries.length > 0 || groupAllowFromEntries.length > 0) { + if (allowNameMatching && (allowFromEntries.length > 0 || groupAllowFromEntries.length > 0)) { const friends = await listZaloFriends(profile); const byName = buildNameIndex(friends, (friend) => friend.displayName); if (allowFromEntries.length > 0) { @@ -869,7 +870,7 @@ export async function monitorZalouserProvider( const groupsConfig = account.config.groups ?? {}; const groupKeys = Object.keys(groupsConfig).filter((key) => key !== "*"); - if (groupKeys.length > 0) { + if (allowNameMatching && groupKeys.length > 0) { const groups = await listZaloGroups(profile); const byName = buildNameIndex(groups, (group) => group.name); const mapping: string[] = [];