From 70da80bcb5574a10925469048d2ebb2abf882e73 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 7 Mar 2026 11:51:20 -0500 Subject: [PATCH] Auto-reply: scope allowlist store writes by account (#39015) * Auto-reply: scope allowlist store writes * Tests: cover allowlist store account scoping * Changelog: note allowlist store scoping hardening --- CHANGELOG.md | 1 + src/auto-reply/reply/commands-allowlist.ts | 47 ++++++++++++---- src/auto-reply/reply/commands.test.ts | 64 ++++++++++++++++++++++ 3 files changed, 102 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63439989d35..fbabb73f56f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -240,6 +240,7 @@ Docs: https://docs.openclaw.ai - Config/compaction safeguard settings: regression-test `agents.defaults.compaction.recentTurnsPreserve` through `loadConfig()` and cover the new help metadata entry so the exposed preserve knob stays wired through schema validation and config UX. (#25557) thanks @rodrigouroz. - iOS/Quick Setup presentation: skip automatic Quick Setup when a gateway is already configured (active connect config, last-known connection, preferred gateway, or manual host), so reconnecting installs no longer get prompted to connect again. (#38964) Thanks @ngutman. - CLI/Docs memory help accuracy: clarify `openclaw memory status --deep` behavior and align memory command examples/docs with the current search options. (#31803) Thanks @JasonOA888 and @Avi974. +- Auto-reply/allowlist store account scoping: keep `/allowlist ... --store` writes scoped to the selected account and clear legacy unscoped entries when removing default-account store access, preventing cross-account default allowlist bleed-through from legacy pairing-store reads. Thanks @vincentkoc. - Security/Nostr: harden profile mutation/import loopback guards by failing closed on non-loopback forwarded client headers (`x-forwarded-for` / `x-real-ip`) and rejecting `sec-fetch-site: cross-site`; adds regression coverage for proxy-forwarded and browser cross-site mutation attempts. ## 2026.3.2 diff --git a/src/auto-reply/reply/commands-allowlist.ts b/src/auto-reply/reply/commands-allowlist.ts index e4b9b7af561..13c79dc796d 100644 --- a/src/auto-reply/reply/commands-allowlist.ts +++ b/src/auto-reply/reply/commands-allowlist.ts @@ -196,6 +196,31 @@ function extractConfigAllowlist(account: { }; } +async function updatePairingStoreAllowlist(params: { + action: "add" | "remove"; + channelId: ChannelId; + accountId?: string; + entry: string; +}) { + const storeEntry = { + channel: params.channelId, + entry: params.entry, + accountId: params.accountId, + }; + if (params.action === "add") { + await addChannelAllowFromStoreEntry(storeEntry); + return; + } + + await removeChannelAllowFromStoreEntry(storeEntry); + if (params.accountId === DEFAULT_ACCOUNT_ID) { + await removeChannelAllowFromStoreEntry({ + channel: params.channelId, + entry: params.entry, + }); + } +} + function resolveAccountTarget( parsed: Record, channelId: ChannelId, @@ -695,11 +720,12 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo } if (shouldTouchStore) { - if (parsed.action === "add") { - await addChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry }); - } else if (parsed.action === "remove") { - await removeChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry }); - } + await updatePairingStoreAllowlist({ + action: parsed.action, + channelId, + accountId, + entry: parsed.entry, + }); } const actionLabel = parsed.action === "add" ? "added" : "removed"; @@ -727,11 +753,12 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo }; } - if (parsed.action === "add") { - await addChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry }); - } else if (parsed.action === "remove") { - await removeChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry }); - } + await updatePairingStoreAllowlist({ + action: parsed.action, + channelId, + accountId, + entry: parsed.entry, + }); const actionLabel = parsed.action === "add" ? "added" : "removed"; const scopeLabel = scope === "dm" ? "DM" : "group"; diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index a83727d2daf..4d31b56a605 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -704,10 +704,74 @@ describe("handleCommands /allowlist", () => { expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({ channel: "telegram", entry: "789", + accountId: "default", }); expect(result.reply?.text).toContain("DM allowlist added"); }); + it("writes store entries to the selected account scope", async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { + channels: { telegram: { accounts: { work: { allowFrom: ["123"] } } } }, + }, + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({ + changed: true, + allowFrom: ["123", "789"], + }); + + const cfg = { + commands: { text: true, config: true }, + channels: { telegram: { accounts: { work: { allowFrom: ["123"] } } } }, + } as OpenClawConfig; + const params = buildPolicyParams("/allowlist add dm --account work 789", cfg, { + AccountId: "work", + }); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({ + channel: "telegram", + entry: "789", + accountId: "work", + }); + }); + + it("removes default-account entries from scoped and legacy pairing stores", async () => { + removeChannelAllowFromStoreEntryMock + .mockResolvedValueOnce({ + changed: true, + allowFrom: [], + }) + .mockResolvedValueOnce({ + changed: true, + allowFrom: [], + }); + + const cfg = { + commands: { text: true, config: true }, + channels: { telegram: { allowFrom: ["123"] } }, + } as OpenClawConfig; + const params = buildPolicyParams("/allowlist remove dm --store 789", cfg); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(removeChannelAllowFromStoreEntryMock).toHaveBeenNthCalledWith(1, { + channel: "telegram", + entry: "789", + accountId: "default", + }); + expect(removeChannelAllowFromStoreEntryMock).toHaveBeenNthCalledWith(2, { + channel: "telegram", + entry: "789", + }); + }); + it("rejects blocked account ids and keeps Object.prototype clean", async () => { delete (Object.prototype as Record).allowFrom;