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
This commit is contained in:
Vincent Koc
2026-03-07 11:51:20 -05:00
committed by GitHub
parent 74912037dc
commit 70da80bcb5
3 changed files with 102 additions and 10 deletions

View File

@@ -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

View File

@@ -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<string, unknown>,
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";

View File

@@ -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<string, unknown>).allowFrom;