Skip to content

Commit ae68006

Browse files
authored
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.
1 parent 735f59a commit ae68006

2 files changed

Lines changed: 36 additions & 6 deletions

File tree

src/sessions/send-policy.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { describe, expect, it } from "vitest";
33
import type { OpenClawConfig } from "../config/config.js";
44
import type { SessionEntry } from "../config/sessions.js";
5+
import { buildAgentPeerSessionKey } from "../routing/session-key.js";
56
import { resolveSendPolicy } from "./send-policy.js";
67

78
describe("resolveSendPolicy", () => {
@@ -73,6 +74,31 @@ describe("resolveSendPolicy", () => {
7374
sessionKey: "demo-channel:direct:user-1",
7475
expected: "deny",
7576
},
77+
{
78+
name: "channel-scoped deny fires for per-account-channel-peer DM key without explicit channel field",
79+
cfg: cfgWithRules([{ action: "deny", match: { channel: "demo-channel" } }]),
80+
sessionKey: buildAgentPeerSessionKey({
81+
agentId: "main",
82+
channel: "demo-channel",
83+
accountId: "acct-1",
84+
peerKind: "direct",
85+
peerId: "user-1",
86+
dmScope: "per-account-channel-peer",
87+
}),
88+
expected: "deny",
89+
},
90+
{
91+
name: "channel-scoped deny ignores later peer-kind-looking tokens in non-channel keys",
92+
cfg: cfgWithRules([{ action: "deny", match: { channel: "demo-channel" } }]),
93+
sessionKey: "demo-channel:not-a-peer-kind:user-1:direct",
94+
expected: "allow",
95+
},
96+
{
97+
name: "channel-scoped deny ignores incomplete account-scoped keys",
98+
cfg: cfgWithRules([{ action: "deny", match: { channel: "demo-channel" } }]),
99+
sessionKey: "demo-channel:acct-1:direct",
100+
expected: "allow",
101+
},
76102
])("$name", ({ cfg, entry, sessionKey, expected }) => {
77103
expect(resolveSendPolicy({ cfg, entry, sessionKey })).toBe(expected);
78104
});

src/sessions/send-policy.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,22 @@ function stripAgentSessionKeyPrefix(key?: string): string | undefined {
4040
return key;
4141
}
4242

43+
const CHANNEL_SESSION_KEY_PEER_KINDS = new Set(["group", "channel", "direct", "dm"]);
44+
4345
function deriveChannelFromKey(key?: string) {
4446
const normalizedKey = stripAgentSessionKeyPrefix(key);
4547
if (!normalizedKey) {
4648
return undefined;
4749
}
4850
const parts = normalizedKey.split(":").filter(Boolean);
49-
// Canonical key layout is <channel>:<peerKind>:<peerId>; parts[0] is the channel
50-
// for direct/dm peers too, so channel-scoped rules also fire for direct chats.
51-
if (
52-
parts.length >= 3 &&
53-
(parts[1] === "group" || parts[1] === "channel" || parts[1] === "direct" || parts[1] === "dm")
54-
) {
51+
// Key layout is <channel>:[<accountId>:]<peerKind>:<peerId>; parts[0] is the
52+
// channel for account-scoped DM keys too, so channel-scoped rules also fire
53+
// for per-account-channel-peer sessions, not just 3-part direct/group keys.
54+
const hasChannelPeerShape =
55+
parts.length >= 3 && CHANNEL_SESSION_KEY_PEER_KINDS.has(parts[1] ?? "");
56+
const hasAccountScopedPeerShape =
57+
parts.length >= 4 && CHANNEL_SESSION_KEY_PEER_KINDS.has(parts[2] ?? "");
58+
if (hasChannelPeerShape || hasAccountScopedPeerShape) {
5559
return normalizeMatchValue(parts[0]);
5660
}
5761
return undefined;

0 commit comments

Comments
 (0)