fix: enforce group tool policy inheritance for subagents (#1557) (thanks @adam91holt)

This commit is contained in:
Peter Steinberger
2026-01-24 05:49:23 +00:00
parent c07949a99c
commit 9d98e55ed5
17 changed files with 152 additions and 6 deletions

View File

@@ -31,6 +31,12 @@ export function createClawdbotTools(options?: {
agentTo?: string; agentTo?: string;
/** Thread/topic identifier for routing replies to the originating thread. */ /** Thread/topic identifier for routing replies to the originating thread. */
agentThreadId?: string | number; agentThreadId?: string | number;
/** Group id for channel-level tool policy inheritance. */
agentGroupId?: string | null;
/** Group channel label for channel-level tool policy inheritance. */
agentGroupChannel?: string | null;
/** Group space label for channel-level tool policy inheritance. */
agentGroupSpace?: string | null;
agentDir?: string; agentDir?: string;
sandboxRoot?: string; sandboxRoot?: string;
workspaceDir?: string; workspaceDir?: string;
@@ -114,6 +120,9 @@ export function createClawdbotTools(options?: {
agentAccountId: options?.agentAccountId, agentAccountId: options?.agentAccountId,
agentTo: options?.agentTo, agentTo: options?.agentTo,
agentThreadId: options?.agentThreadId, agentThreadId: options?.agentThreadId,
agentGroupId: options?.agentGroupId,
agentGroupChannel: options?.agentGroupChannel,
agentGroupSpace: options?.agentGroupSpace,
sandboxed: options?.sandboxed, sandboxed: options?.sandboxed,
}), }),
createSessionStatusTool({ createSessionStatusTool({

View File

@@ -73,6 +73,14 @@ export async function compactEmbeddedPiSession(params: {
messageChannel?: string; messageChannel?: string;
messageProvider?: string; messageProvider?: string;
agentAccountId?: string; agentAccountId?: string;
/** Group id for channel-level tool policy resolution. */
groupId?: string | null;
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
groupChannel?: string | null;
/** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */
groupSpace?: string | null;
/** Parent session key for subagent policy inheritance. */
spawnedBy?: string | null;
sessionFile: string; sessionFile: string;
workspaceDir: string; workspaceDir: string;
agentDir?: string; agentDir?: string;
@@ -207,6 +215,10 @@ export async function compactEmbeddedPiSession(params: {
messageProvider: params.messageChannel ?? params.messageProvider, messageProvider: params.messageChannel ?? params.messageProvider,
agentAccountId: params.agentAccountId, agentAccountId: params.agentAccountId,
sessionKey: params.sessionKey ?? params.sessionId, sessionKey: params.sessionKey ?? params.sessionId,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
agentDir, agentDir,
workspaceDir: effectiveWorkspace, workspaceDir: effectiveWorkspace,
config: params.config, config: params.config,

View File

@@ -267,6 +267,7 @@ export async function runEmbeddedPiAgent(
groupId: params.groupId, groupId: params.groupId,
groupChannel: params.groupChannel, groupChannel: params.groupChannel,
groupSpace: params.groupSpace, groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
currentChannelId: params.currentChannelId, currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs, currentThreadTs: params.currentThreadTs,
replyToMode: params.replyToMode, replyToMode: params.replyToMode,

View File

@@ -211,6 +211,7 @@ export async function runEmbeddedAttempt(
groupId: params.groupId, groupId: params.groupId,
groupChannel: params.groupChannel, groupChannel: params.groupChannel,
groupSpace: params.groupSpace, groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
sessionKey: params.sessionKey ?? params.sessionId, sessionKey: params.sessionKey ?? params.sessionId,
agentDir, agentDir,
workspaceDir: effectiveWorkspace, workspaceDir: effectiveWorkspace,

View File

@@ -33,6 +33,8 @@ export type RunEmbeddedPiAgentParams = {
groupChannel?: string | null; groupChannel?: string | null;
/** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */ /** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */
groupSpace?: string | null; groupSpace?: string | null;
/** Parent session key for subagent policy inheritance. */
spawnedBy?: string | null;
/** Current channel ID for auto-threading (Slack). */ /** Current channel ID for auto-threading (Slack). */
currentChannelId?: string; currentChannelId?: string;
/** Current thread timestamp for auto-threading (Slack). */ /** Current thread timestamp for auto-threading (Slack). */

View File

@@ -29,6 +29,8 @@ export type EmbeddedRunAttemptParams = {
groupChannel?: string | null; groupChannel?: string | null;
/** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */ /** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */
groupSpace?: string | null; groupSpace?: string | null;
/** Parent session key for subagent policy inheritance. */
spawnedBy?: string | null;
currentChannelId?: string; currentChannelId?: string;
currentThreadTs?: string; currentThreadTs?: string;
replyToMode?: "off" | "first" | "all"; replyToMode?: "off" | "first" | "all";

View File

@@ -295,6 +295,31 @@ describe("Agent-specific tool filtering", () => {
expect(names).not.toContain("exec"); expect(names).not.toContain("exec");
}); });
it("should inherit group tool policy for subagents from spawnedBy session keys", () => {
const cfg: ClawdbotConfig = {
channels: {
whatsapp: {
groups: {
trusted: {
tools: { allow: ["read"] },
},
},
},
},
};
const tools = createClawdbotCodingTools({
config: cfg,
sessionKey: "agent:main:subagent:test",
spawnedBy: "agent:main:whatsapp:group:trusted",
workspaceDir: "/tmp/test-subagent-group",
agentDir: "/tmp/agent-subagent",
});
const names = tools.map((t) => t.name);
expect(names).toContain("read");
expect(names).not.toContain("exec");
});
it("should apply global tool policy before agent-specific policy", () => { it("should apply global tool policy before agent-specific policy", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
tools: { tools: {

View File

@@ -120,7 +120,10 @@ function resolveGroupContextFromSessionKey(sessionKey?: string | null): {
if (!raw) return {}; if (!raw) return {};
const base = resolveThreadParentSessionKey(raw) ?? raw; const base = resolveThreadParentSessionKey(raw) ?? raw;
const parts = base.split(":").filter(Boolean); const parts = base.split(":").filter(Boolean);
const body = parts[0] === "agent" ? parts.slice(2) : parts; let body = parts[0] === "agent" ? parts.slice(2) : parts;
if (body[0] === "subagent") {
body = body.slice(1);
}
if (body.length < 3) return {}; if (body.length < 3) return {};
const [channel, kind, ...rest] = body; const [channel, kind, ...rest] = body;
if (kind !== "group" && kind !== "channel") return {}; if (kind !== "group" && kind !== "channel") return {};
@@ -198,6 +201,7 @@ export function resolveEffectiveToolPolicy(params: {
export function resolveGroupToolPolicy(params: { export function resolveGroupToolPolicy(params: {
config?: ClawdbotConfig; config?: ClawdbotConfig;
sessionKey?: string; sessionKey?: string;
spawnedBy?: string | null;
messageProvider?: string; messageProvider?: string;
groupId?: string | null; groupId?: string | null;
groupChannel?: string | null; groupChannel?: string | null;
@@ -206,9 +210,10 @@ export function resolveGroupToolPolicy(params: {
}): SandboxToolPolicy | undefined { }): SandboxToolPolicy | undefined {
if (!params.config) return undefined; if (!params.config) return undefined;
const sessionContext = resolveGroupContextFromSessionKey(params.sessionKey); const sessionContext = resolveGroupContextFromSessionKey(params.sessionKey);
const groupId = params.groupId ?? sessionContext.groupId; const spawnedContext = resolveGroupContextFromSessionKey(params.spawnedBy);
const groupId = params.groupId ?? sessionContext.groupId ?? spawnedContext.groupId;
if (!groupId) return undefined; if (!groupId) return undefined;
const channelRaw = params.messageProvider ?? sessionContext.channel; const channelRaw = params.messageProvider ?? sessionContext.channel ?? spawnedContext.channel;
const channel = normalizeMessageChannel(channelRaw); const channel = normalizeMessageChannel(channelRaw);
if (!channel) return undefined; if (!channel) return undefined;
let dock; let dock;

View File

@@ -135,6 +135,8 @@ export function createClawdbotCodingTools(options?: {
groupChannel?: string | null; groupChannel?: string | null;
/** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */ /** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */
groupSpace?: string | null; groupSpace?: string | null;
/** Parent session key for subagent group policy inheritance. */
spawnedBy?: string | null;
/** Reply-to mode for Slack auto-threading. */ /** Reply-to mode for Slack auto-threading. */
replyToMode?: "off" | "first" | "all"; replyToMode?: "off" | "first" | "all";
/** Mutable ref to track if a reply was sent (for "first" mode). */ /** Mutable ref to track if a reply was sent (for "first" mode). */
@@ -161,6 +163,7 @@ export function createClawdbotCodingTools(options?: {
const groupPolicy = resolveGroupToolPolicy({ const groupPolicy = resolveGroupToolPolicy({
config: options?.config, config: options?.config,
sessionKey: options?.sessionKey, sessionKey: options?.sessionKey,
spawnedBy: options?.spawnedBy,
messageProvider: options?.messageProvider, messageProvider: options?.messageProvider,
groupId: options?.groupId, groupId: options?.groupId,
groupChannel: options?.groupChannel, groupChannel: options?.groupChannel,
@@ -290,6 +293,9 @@ export function createClawdbotCodingTools(options?: {
agentAccountId: options?.agentAccountId, agentAccountId: options?.agentAccountId,
agentTo: options?.messageTo, agentTo: options?.messageTo,
agentThreadId: options?.messageThreadId, agentThreadId: options?.messageThreadId,
agentGroupId: options?.groupId ?? null,
agentGroupChannel: options?.groupChannel ?? null,
agentGroupSpace: options?.groupSpace ?? null,
agentDir: options?.agentDir, agentDir: options?.agentDir,
sandboxRoot, sandboxRoot,
workspaceDir: options?.workspaceDir, workspaceDir: options?.workspaceDir,

View File

@@ -63,6 +63,9 @@ export function createSessionsSpawnTool(opts?: {
agentAccountId?: string; agentAccountId?: string;
agentTo?: string; agentTo?: string;
agentThreadId?: string | number; agentThreadId?: string | number;
agentGroupId?: string | null;
agentGroupChannel?: string | null;
agentGroupSpace?: string | null;
sandboxed?: boolean; sandboxed?: boolean;
}): AnyAgentTool { }): AnyAgentTool {
return { return {
@@ -153,7 +156,7 @@ export function createSessionsSpawnTool(opts?: {
} }
} }
const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`; const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`;
const shouldPatchSpawnedBy = opts?.sandboxed === true; const spawnedByKey = requesterInternalKey;
const targetAgentConfig = resolveAgentConfig(cfg, targetAgentId); const targetAgentConfig = resolveAgentConfig(cfg, targetAgentId);
const resolvedModel = const resolvedModel =
normalizeModelSelection(modelOverride) ?? normalizeModelSelection(modelOverride) ??
@@ -219,7 +222,10 @@ export function createSessionsSpawnTool(opts?: {
thinking: thinkingOverride, thinking: thinkingOverride,
timeout: runTimeoutSeconds > 0 ? runTimeoutSeconds : undefined, timeout: runTimeoutSeconds > 0 ? runTimeoutSeconds : undefined,
label: label || undefined, label: label || undefined,
spawnedBy: shouldPatchSpawnedBy ? requesterInternalKey : undefined, spawnedBy: spawnedByKey,
groupId: opts?.agentGroupId ?? undefined,
groupChannel: opts?.agentGroupChannel ?? undefined,
groupSpace: opts?.agentGroupSpace ?? undefined,
}, },
timeoutMs: 10_000, timeoutMs: 10_000,
})) as { runId?: string }; })) as { runId?: string };

View File

@@ -67,6 +67,10 @@ export const handleCompactCommand: CommandHandler = async (params) => {
sessionId, sessionId,
sessionKey: params.sessionKey, sessionKey: params.sessionKey,
messageChannel: params.command.channel, messageChannel: params.command.channel,
groupId: params.sessionEntry.groupId,
groupChannel: params.sessionEntry.groupChannel,
groupSpace: params.sessionEntry.space,
spawnedBy: params.sessionEntry.spawnedBy,
sessionFile: resolveSessionFilePath(sessionId, params.sessionEntry), sessionFile: resolveSessionFilePath(sessionId, params.sessionEntry),
workspaceDir: params.workspaceDir, workspaceDir: params.workspaceDir,
config: params.cfg, config: params.cfg,

View File

@@ -81,6 +81,10 @@ async function resolveContextReport(
workspaceDir, workspaceDir,
sessionKey: params.sessionKey, sessionKey: params.sessionKey,
messageProvider: params.command.channel, messageProvider: params.command.channel,
groupId: params.sessionEntry?.groupId ?? undefined,
groupChannel: params.sessionEntry?.groupChannel ?? undefined,
groupSpace: params.sessionEntry?.space ?? undefined,
spawnedBy: params.sessionEntry?.spawnedBy ?? undefined,
modelProvider: params.provider, modelProvider: params.provider,
modelId: params.model, modelId: params.model,
}); });

View File

@@ -377,6 +377,7 @@ export async function agentCommand(
runContext.messageChannel, runContext.messageChannel,
opts.replyChannel ?? opts.channel, opts.replyChannel ?? opts.channel,
); );
const spawnedBy = opts.spawnedBy ?? sessionEntry?.spawnedBy;
const fallbackResult = await runWithModelFallback({ const fallbackResult = await runWithModelFallback({
cfg, cfg,
provider, provider,
@@ -412,6 +413,10 @@ export async function agentCommand(
agentAccountId: runContext.accountId, agentAccountId: runContext.accountId,
messageTo: opts.replyTo ?? opts.to, messageTo: opts.replyTo ?? opts.to,
messageThreadId: opts.threadId, messageThreadId: opts.threadId,
groupId: runContext.groupId,
groupChannel: runContext.groupChannel,
groupSpace: runContext.groupSpace,
spawnedBy,
currentChannelId: runContext.currentChannelId, currentChannelId: runContext.currentChannelId,
currentThreadTs: runContext.currentThreadTs, currentThreadTs: runContext.currentThreadTs,
replyToMode: runContext.replyToMode, replyToMode: runContext.replyToMode,

View File

@@ -14,6 +14,15 @@ export function resolveAgentRunContext(opts: AgentCommandOpts): AgentRunContext
const normalizedAccountId = normalizeAccountId(merged.accountId ?? opts.accountId); const normalizedAccountId = normalizeAccountId(merged.accountId ?? opts.accountId);
if (normalizedAccountId) merged.accountId = normalizedAccountId; if (normalizedAccountId) merged.accountId = normalizedAccountId;
const groupId = (merged.groupId ?? opts.groupId)?.toString().trim();
if (groupId) merged.groupId = groupId;
const groupChannel = (merged.groupChannel ?? opts.groupChannel)?.toString().trim();
if (groupChannel) merged.groupChannel = groupChannel;
const groupSpace = (merged.groupSpace ?? opts.groupSpace)?.toString().trim();
if (groupSpace) merged.groupSpace = groupSpace;
if ( if (
merged.currentThreadTs == null && merged.currentThreadTs == null &&
opts.threadId != null && opts.threadId != null &&

View File

@@ -17,6 +17,9 @@ export type AgentStreamParams = {
export type AgentRunContext = { export type AgentRunContext = {
messageChannel?: string; messageChannel?: string;
accountId?: string; accountId?: string;
groupId?: string | null;
groupChannel?: string | null;
groupSpace?: string | null;
currentChannelId?: string; currentChannelId?: string;
currentThreadTs?: string; currentThreadTs?: string;
replyToMode?: "off" | "first" | "all"; replyToMode?: "off" | "first" | "all";
@@ -55,6 +58,14 @@ export type AgentCommandOpts = {
accountId?: string; accountId?: string;
/** Context for embedded run routing (channel/account/thread). */ /** Context for embedded run routing (channel/account/thread). */
runContext?: AgentRunContext; runContext?: AgentRunContext;
/** Group id for channel-level tool policy resolution. */
groupId?: string | null;
/** Group channel label for channel-level tool policy resolution. */
groupChannel?: string | null;
/** Group space label for channel-level tool policy resolution. */
groupSpace?: string | null;
/** Parent session key for subagent policy inheritance. */
spawnedBy?: string | null;
deliveryTargetMode?: ChannelOutboundTargetMode; deliveryTargetMode?: ChannelOutboundTargetMode;
bestEffortDeliver?: boolean; bestEffortDeliver?: boolean;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;

View File

@@ -59,6 +59,9 @@ export const AgentParamsSchema = Type.Object(
accountId: Type.Optional(Type.String()), accountId: Type.Optional(Type.String()),
replyAccountId: Type.Optional(Type.String()), replyAccountId: Type.Optional(Type.String()),
threadId: Type.Optional(Type.String()), threadId: Type.Optional(Type.String()),
groupId: Type.Optional(Type.String()),
groupChannel: Type.Optional(Type.String()),
groupSpace: Type.Optional(Type.String()),
timeout: Type.Optional(Type.Integer({ minimum: 0 })), timeout: Type.Optional(Type.Integer({ minimum: 0 })),
lane: Type.Optional(Type.String()), lane: Type.Optional(Type.String()),
extraSystemPrompt: Type.Optional(Type.String()), extraSystemPrompt: Type.Optional(Type.String()),

View File

@@ -76,6 +76,9 @@ export const agentHandlers: GatewayRequestHandlers = {
accountId?: string; accountId?: string;
replyAccountId?: string; replyAccountId?: string;
threadId?: string; threadId?: string;
groupId?: string;
groupChannel?: string;
groupSpace?: string;
lane?: string; lane?: string;
extraSystemPrompt?: string; extraSystemPrompt?: string;
idempotencyKey: string; idempotencyKey: string;
@@ -85,6 +88,15 @@ export const agentHandlers: GatewayRequestHandlers = {
}; };
const cfg = loadConfig(); const cfg = loadConfig();
const idem = request.idempotencyKey; const idem = request.idempotencyKey;
const groupIdRaw = typeof request.groupId === "string" ? request.groupId.trim() : "";
const groupChannelRaw =
typeof request.groupChannel === "string" ? request.groupChannel.trim() : "";
const groupSpaceRaw = typeof request.groupSpace === "string" ? request.groupSpace.trim() : "";
let resolvedGroupId: string | undefined = groupIdRaw || undefined;
let resolvedGroupChannel: string | undefined = groupChannelRaw || undefined;
let resolvedGroupSpace: string | undefined = groupSpaceRaw || undefined;
let spawnedByValue =
typeof request.spawnedBy === "string" ? request.spawnedBy.trim() : undefined;
const cached = context.dedupe.get(`agent:${idem}`); const cached = context.dedupe.get(`agent:${idem}`);
if (cached) { if (cached) {
respond(cached.ok, cached.payload, cached.error, { respond(cached.ok, cached.payload, cached.error, {
@@ -198,7 +210,25 @@ export const agentHandlers: GatewayRequestHandlers = {
const now = Date.now(); const now = Date.now();
const sessionId = entry?.sessionId ?? randomUUID(); const sessionId = entry?.sessionId ?? randomUUID();
const labelValue = request.label?.trim() || entry?.label; const labelValue = request.label?.trim() || entry?.label;
const spawnedByValue = request.spawnedBy?.trim() || entry?.spawnedBy; spawnedByValue = spawnedByValue || entry?.spawnedBy;
let inheritedGroup:
| { groupId?: string; groupChannel?: string; groupSpace?: string }
| undefined;
if (spawnedByValue && (!resolvedGroupId || !resolvedGroupChannel || !resolvedGroupSpace)) {
try {
const parentEntry = loadSessionEntry(spawnedByValue)?.entry;
inheritedGroup = {
groupId: parentEntry?.groupId,
groupChannel: parentEntry?.groupChannel,
groupSpace: parentEntry?.space,
};
} catch {
inheritedGroup = undefined;
}
}
resolvedGroupId = resolvedGroupId || inheritedGroup?.groupId;
resolvedGroupChannel = resolvedGroupChannel || inheritedGroup?.groupChannel;
resolvedGroupSpace = resolvedGroupSpace || inheritedGroup?.groupSpace;
const deliveryFields = normalizeSessionDeliveryFields(entry); const deliveryFields = normalizeSessionDeliveryFields(entry);
const nextEntry: SessionEntry = { const nextEntry: SessionEntry = {
sessionId, sessionId,
@@ -217,6 +247,10 @@ export const agentHandlers: GatewayRequestHandlers = {
providerOverride: entry?.providerOverride, providerOverride: entry?.providerOverride,
label: labelValue, label: labelValue,
spawnedBy: spawnedByValue, spawnedBy: spawnedByValue,
channel: entry?.channel ?? request.channel?.trim(),
groupId: resolvedGroupId ?? entry?.groupId,
groupChannel: resolvedGroupChannel ?? entry?.groupChannel,
space: resolvedGroupSpace ?? entry?.space,
}; };
sessionEntry = nextEntry; sessionEntry = nextEntry;
const sendPolicy = resolveSendPolicy({ const sendPolicy = resolveSendPolicy({
@@ -326,8 +360,15 @@ export const agentHandlers: GatewayRequestHandlers = {
runContext: { runContext: {
messageChannel: resolvedChannel, messageChannel: resolvedChannel,
accountId: resolvedAccountId, accountId: resolvedAccountId,
groupId: resolvedGroupId,
groupChannel: resolvedGroupChannel,
groupSpace: resolvedGroupSpace,
currentThreadTs: resolvedThreadId != null ? String(resolvedThreadId) : undefined, currentThreadTs: resolvedThreadId != null ? String(resolvedThreadId) : undefined,
}, },
groupId: resolvedGroupId,
groupChannel: resolvedGroupChannel,
groupSpace: resolvedGroupSpace,
spawnedBy: spawnedByValue,
timeout: request.timeout?.toString(), timeout: request.timeout?.toString(),
bestEffortDeliver, bestEffortDeliver,
messageChannel: resolvedChannel, messageChannel: resolvedChannel,