fix: resolve scoped group tool policies (#64491)

This commit is contained in:
Peter Steinberger
2026-04-10 23:26:52 +01:00
parent c94888dbee
commit 9cbfbd18e3
3 changed files with 130 additions and 32 deletions

View File

@@ -514,6 +514,34 @@ describe("Agent-specific tool filtering", () => {
expect(names).not.toContain("exec");
});
it("should prefer scoped group candidates before wildcard tool policy", () => {
const cfg: OpenClawConfig = {
channels: {
feishu: {
groups: {
"*": {
tools: { allow: ["read", "exec"] },
},
oc_group_chat: {
tools: { allow: ["read"] },
},
},
},
},
};
const tools = createOpenClawCodingTools({
config: cfg,
sessionKey: "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
messageProvider: "feishu",
workspaceDir: "/tmp/test-feishu-wildcard-group",
agentDir: "/tmp/agent-feishu-wildcard",
});
const names = tools.map((t) => t.name);
expect(names).toContain("read");
expect(names).not.toContain("exec");
});
it("should resolve inherited group tool policy for subagent parent groups", () => {
const cfg: OpenClawConfig = {
channels: {

View File

@@ -136,9 +136,47 @@ function normalizeProviderKey(value: string): string {
return normalizeLowercaseStringOrEmpty(value);
}
function collectUniqueStrings(values: Array<string | null | undefined>): string[] {
const seen = new Set<string>();
const resolved: string[] = [];
for (const value of values) {
const trimmed = value?.trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
resolved.push(trimmed);
}
return resolved;
}
function buildScopedGroupIdCandidates(groupId?: string | null): string[] {
const raw = groupId?.trim();
if (!raw) {
return [];
}
const topicSenderMatch = raw.match(/^(.+):topic:([^:]+):sender:([^:]+)$/i);
if (topicSenderMatch) {
const [, chatId, topicId] = topicSenderMatch;
// Sender-scoped sessions still inherit topic/base group tool policies.
return collectUniqueStrings([raw, `${chatId}:topic:${topicId}`, chatId]);
}
const topicMatch = raw.match(/^(.+):topic:([^:]+)$/i);
if (topicMatch) {
const [, chatId, topicId] = topicMatch;
return collectUniqueStrings([`${chatId}:topic:${topicId}`, chatId]);
}
const senderMatch = raw.match(/^(.+):sender:([^:]+)$/i);
if (senderMatch) {
const [, chatId] = senderMatch;
return collectUniqueStrings([raw, chatId]);
}
return [raw];
}
function resolveGroupContextFromSessionKey(sessionKey?: string | null): {
channel?: string;
groupId?: string;
groupIds?: string[];
} {
const raw = (sessionKey ?? "").trim();
if (!raw) {
@@ -148,21 +186,22 @@ function resolveGroupContextFromSessionKey(sessionKey?: string | null): {
const conversationKey = threadId ? baseSessionKey : raw;
const conversation = parseRawSessionConversationRef(conversationKey);
if (conversation) {
if (/:(?:sender|thread|topic):/iu.test(conversation.rawId)) {
const resolvedConversation = resolveSessionConversation({
channel: conversation.channel,
kind: conversation.kind,
rawId: conversation.rawId,
});
const groupId = resolvedConversation?.baseConversationId;
if (groupId) {
return {
const resolvedConversation = /:(?:sender|thread|topic):/iu.test(conversation.rawId)
? resolveSessionConversation({
channel: conversation.channel,
groupId,
};
}
}
return { channel: conversation.channel, groupId: conversation.rawId };
kind: conversation.kind,
rawId: conversation.rawId,
})
: null;
return {
channel: conversation.channel,
groupIds: collectUniqueStrings([
...buildScopedGroupIdCandidates(conversation.rawId),
resolvedConversation?.id,
resolvedConversation?.baseConversationId,
...(resolvedConversation?.parentConversationCandidates ?? []),
]),
};
}
const base = conversationKey ?? raw;
const parts = base.split(":").filter(Boolean);
@@ -181,7 +220,10 @@ function resolveGroupContextFromSessionKey(sessionKey?: string | null): {
if (!groupId) {
return {};
}
return { channel: normalizeLowercaseStringOrEmpty(channel), groupId };
return {
channel: normalizeLowercaseStringOrEmpty(channel),
groupIds: buildScopedGroupIdCandidates(groupId),
};
}
function resolveProviderToolPolicy(params: {
@@ -331,8 +373,12 @@ export function resolveGroupToolPolicy(params: {
}
const sessionContext = resolveGroupContextFromSessionKey(params.sessionKey);
const spawnedContext = resolveGroupContextFromSessionKey(params.spawnedBy);
const groupId = params.groupId ?? sessionContext.groupId ?? spawnedContext.groupId;
if (!groupId) {
const groupIds = collectUniqueStrings([
...buildScopedGroupIdCandidates(params.groupId),
...(sessionContext.groupIds ?? []),
...(spawnedContext.groupIds ?? []),
]);
if (groupIds.length === 0) {
return undefined;
}
const channelRaw = params.messageProvider ?? sessionContext.channel ?? spawnedContext.channel;
@@ -346,8 +392,8 @@ export function resolveGroupToolPolicy(params: {
} catch {
plugin = undefined;
}
const toolsConfig =
plugin?.groups?.resolveToolPolicy?.({
for (const groupId of groupIds) {
const toolsConfig = plugin?.groups?.resolveToolPolicy?.({
cfg: params.config,
groupId,
groupChannel: params.groupChannel,
@@ -357,18 +403,24 @@ export function resolveGroupToolPolicy(params: {
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
}) ??
resolveChannelGroupToolsPolicy({
cfg: params.config,
channel,
groupId,
accountId: params.accountId,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
return pickSandboxToolPolicy(toolsConfig);
const policy = pickSandboxToolPolicy(toolsConfig);
if (policy) {
return policy;
}
}
const configTools = resolveChannelGroupToolsPolicy({
cfg: params.config,
channel,
groupId: groupIds[0],
groupIdCandidates: groupIds.slice(1),
accountId: params.accountId,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
return pickSandboxToolPolicy(configTools);
}
export { isToolAllowedByPolicies, isToolAllowedByPolicyName } from "./tool-policy-match.js";

View File

@@ -400,11 +400,29 @@ export function resolveChannelGroupToolsPolicy(
cfg: OpenClawConfig;
channel: GroupPolicyChannel;
groupId?: string | null;
groupIdCandidates?: Array<string | null | undefined>;
accountId?: string | null;
groupIdCaseInsensitive?: boolean;
} & GroupToolPolicySender,
): GroupToolPolicyConfig | undefined {
const { groupConfig, defaultConfig } = resolveChannelGroupPolicy(params);
const groups = resolveChannelGroups(params.cfg, params.channel, params.accountId);
const groupIds = [
params.groupId,
...(Array.isArray(params.groupIdCandidates) ? params.groupIdCandidates : []),
];
let groupConfig: ChannelGroupConfig | undefined;
for (const rawGroupId of groupIds) {
const groupId = rawGroupId?.trim();
if (!groupId) {
continue;
}
// Scoped ids can collapse to a parent group; try all exact matches before wildcard fallback.
groupConfig = resolveChannelGroupConfig(groups, groupId, params.groupIdCaseInsensitive);
if (groupConfig) {
break;
}
}
const defaultConfig = groups?.["*"];
const groupSenderPolicy = resolveToolsBySender({
toolsBySender: groupConfig?.toolsBySender,
senderId: params.senderId,