fix(sessions): enforce channel send policy for account-scoped DMs

Derive the channel from canonical account-scoped DM session keys when resolving session.sendPolicy, so channel-scoped allow/deny rules apply to per-account-channel-peer sessions.

Keep derivation limited to canonical channel peer key shapes and add malformed-key regressions so incomplete or non-channel keys do not accidentally match channel rules.

Compatibility note: existing channel-scoped send-policy rules can now block account-scoped DM sends that were previously allowed by this bug.

Thanks @yetval for the fix.
This commit is contained in:
Yuval Dinodia
2026-06-13 17:38:42 -04:00
committed by GitHub
parent 735f59af73
commit ae68006a8f
2 changed files with 36 additions and 6 deletions
+26
View File
@@ -2,6 +2,7 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { SessionEntry } from "../config/sessions.js";
import { buildAgentPeerSessionKey } from "../routing/session-key.js";
import { resolveSendPolicy } from "./send-policy.js";
describe("resolveSendPolicy", () => {
@@ -73,6 +74,31 @@ describe("resolveSendPolicy", () => {
sessionKey: "demo-channel:direct:user-1",
expected: "deny",
},
{
name: "channel-scoped deny fires for per-account-channel-peer DM key without explicit channel field",
cfg: cfgWithRules([{ action: "deny", match: { channel: "demo-channel" } }]),
sessionKey: buildAgentPeerSessionKey({
agentId: "main",
channel: "demo-channel",
accountId: "acct-1",
peerKind: "direct",
peerId: "user-1",
dmScope: "per-account-channel-peer",
}),
expected: "deny",
},
{
name: "channel-scoped deny ignores later peer-kind-looking tokens in non-channel keys",
cfg: cfgWithRules([{ action: "deny", match: { channel: "demo-channel" } }]),
sessionKey: "demo-channel:not-a-peer-kind:user-1:direct",
expected: "allow",
},
{
name: "channel-scoped deny ignores incomplete account-scoped keys",
cfg: cfgWithRules([{ action: "deny", match: { channel: "demo-channel" } }]),
sessionKey: "demo-channel:acct-1:direct",
expected: "allow",
},
])("$name", ({ cfg, entry, sessionKey, expected }) => {
expect(resolveSendPolicy({ cfg, entry, sessionKey })).toBe(expected);
});
+10 -6
View File
@@ -40,18 +40,22 @@ function stripAgentSessionKeyPrefix(key?: string): string | undefined {
return key;
}
const CHANNEL_SESSION_KEY_PEER_KINDS = new Set(["group", "channel", "direct", "dm"]);
function deriveChannelFromKey(key?: string) {
const normalizedKey = stripAgentSessionKeyPrefix(key);
if (!normalizedKey) {
return undefined;
}
const parts = normalizedKey.split(":").filter(Boolean);
// Canonical key layout is <channel>:<peerKind>:<peerId>; parts[0] is the channel
// for direct/dm peers too, so channel-scoped rules also fire for direct chats.
if (
parts.length >= 3 &&
(parts[1] === "group" || parts[1] === "channel" || parts[1] === "direct" || parts[1] === "dm")
) {
// Key layout is <channel>:[<accountId>:]<peerKind>:<peerId>; parts[0] is the
// channel for account-scoped DM keys too, so channel-scoped rules also fire
// for per-account-channel-peer sessions, not just 3-part direct/group keys.
const hasChannelPeerShape =
parts.length >= 3 && CHANNEL_SESSION_KEY_PEER_KINDS.has(parts[1] ?? "");
const hasAccountScopedPeerShape =
parts.length >= 4 && CHANNEL_SESSION_KEY_PEER_KINDS.has(parts[2] ?? "");
if (hasChannelPeerShape || hasAccountScopedPeerShape) {
return normalizeMatchValue(parts[0]);
}
return undefined;