From a55b4dcb7ef82f26e1f42d05ccc72b3a751ead76 Mon Sep 17 00:00:00 2001 From: Gustav Botichelli Date: Thu, 29 Jan 2026 22:28:42 -0500 Subject: [PATCH] feat(sessions): add toolPolicy param to sessions_spawn for sub-agent tool restrictions Add a toolPolicy parameter to sessions_spawn that allows the parent agent to restrict the tool set available to spawned sub-agents. The new toolPolicy parameter accepts allow and deny arrays: - allow: Narrows the sub-agent to only these tools (on top of default deny list) - deny: Additional tools/groups to block (appended to default deny list) Supports tool group syntax (group:web, group:fs, group:runtime, etc.) Implementation: - SessionEntry gains spawnToolPolicy field (persisted per-session) - sessions.patch accepts spawnToolPolicy for subagent sessions - Policy is immutable once set (cannot be changed after spawn) - Merges with existing default subagent deny list - Applied in both embedded agent path and HTTP tool invoke path --- src/agents/pi-embedded-runner/run.ts | 1 + src/agents/pi-embedded-runner/run/attempt.ts | 1 + src/agents/pi-embedded-runner/run/params.ts | 2 + src/agents/pi-embedded-runner/run/types.ts | 2 + src/agents/pi-tools.ts | 21 +++++++++- src/agents/tools/sessions-spawn-tool.ts | 43 ++++++++++++++++++++ src/commands/agent.ts | 1 + src/config/sessions/types.ts | 2 + src/gateway/protocol/schema/sessions.ts | 9 ++++ src/gateway/server-methods/agent.ts | 1 + src/gateway/sessions-patch.ts | 24 +++++++++++ src/gateway/tools-invoke-http.ts | 22 +++++++++- 12 files changed, 127 insertions(+), 2 deletions(-) diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 870453f38..1ec055721 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -313,6 +313,7 @@ export async function runEmbeddedPiAgent( groupChannel: params.groupChannel, groupSpace: params.groupSpace, spawnedBy: params.spawnedBy, + spawnToolPolicy: params.spawnToolPolicy, currentChannelId: params.currentChannelId, currentThreadTs: params.currentThreadTs, replyToMode: params.replyToMode, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 46a53bd8f..f638f567d 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -215,6 +215,7 @@ export async function runEmbeddedAttempt( groupChannel: params.groupChannel, groupSpace: params.groupSpace, spawnedBy: params.spawnedBy, + spawnToolPolicy: params.spawnToolPolicy, senderId: params.senderId, senderName: params.senderName, senderUsername: params.senderUsername, diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index b21a5e3fc..605cf9129 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -35,6 +35,8 @@ export type RunEmbeddedPiAgentParams = { groupSpace?: string | null; /** Parent session key for subagent policy inheritance. */ spawnedBy?: string | null; + /** Per-spawn tool policy override from sessions_spawn toolPolicy param. */ + spawnToolPolicy?: { allow?: string[]; deny?: string[] }; senderId?: string | null; senderName?: string | null; senderUsername?: string | null; diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index 92bb3ff46..db6f106f7 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -31,6 +31,8 @@ export type EmbeddedRunAttemptParams = { groupSpace?: string | null; /** Parent session key for subagent policy inheritance. */ spawnedBy?: string | null; + /** Per-spawn tool policy override from sessions_spawn toolPolicy param. */ + spawnToolPolicy?: { allow?: string[]; deny?: string[] }; senderId?: string | null; senderName?: string | null; senderUsername?: string | null; diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index d763393a4..690f4beda 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -150,6 +150,8 @@ export function createMoltbotCodingTools(options?: { hasRepliedRef?: { value: boolean }; /** If true, the model has native vision capability */ modelHasVision?: boolean; + /** Per-spawn tool policy override from sessions_spawn toolPolicy param. */ + spawnToolPolicy?: { allow?: string[]; deny?: string[] }; }): AnyAgentTool[] { const execToolName = "exec"; const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined; @@ -197,10 +199,27 @@ export function createMoltbotCodingTools(options?: { providerProfileAlsoAllow, ); const scopeKey = options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined); - const subagentPolicy = + const baseSubagentPolicy = isSubagentSessionKey(options?.sessionKey) && options?.sessionKey ? resolveSubagentToolPolicy(options.config) : undefined; + const subagentPolicy = (() => { + if (!baseSubagentPolicy) return undefined; + const spawnPolicy = options?.spawnToolPolicy; + if (!spawnPolicy) return baseSubagentPolicy; + // Merge spawn-time policy: spawn allow narrows default, spawn deny appends to default. + const mergedDeny = [ + ...(baseSubagentPolicy.deny ?? []), + ...(spawnPolicy.deny ?? []), + ]; + const mergedAllow = spawnPolicy.allow + ? spawnPolicy.allow + : baseSubagentPolicy.allow; + return { + allow: mergedAllow, + deny: mergedDeny.length > 0 ? mergedDeny : undefined, + }; + })(); const allowBackground = isToolAllowedByPolicies("process", [ profilePolicyWithAlsoAllow, providerProfilePolicyWithAlsoAllow, diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index e5e1391d1..6d9d10ff0 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -31,6 +31,28 @@ const SessionsSpawnToolSchema = Type.Object({ agentId: Type.Optional(Type.String()), model: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()), + toolPolicy: Type.Optional( + Type.Object( + { + allow: Type.Optional( + Type.Array(Type.String(), { + description: + 'Tool names or groups to allow (e.g. "web_fetch", "read", "group:web"). When set, only these tools are available.', + }), + ), + deny: Type.Optional( + Type.Array(Type.String(), { + description: + 'Tool names or groups to deny (e.g. "exec", "group:runtime"). Appended to default subagent deny list.', + }), + ), + }, + { + description: + "Restrict available tools for the sub-agent. allow narrows to listed tools; deny blocks listed tools (additive with defaults).", + }, + ), + ), runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), // Back-compat alias. Prefer runTimeoutSeconds. timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), @@ -83,6 +105,10 @@ export function createSessionsSpawnTool(opts?: { const requestedAgentId = readStringParam(params, "agentId"); const modelOverride = readStringParam(params, "model"); const thinkingOverrideRaw = readStringParam(params, "thinking"); + const toolPolicy = + params.toolPolicy && typeof params.toolPolicy === "object" + ? (params.toolPolicy as { allow?: string[]; deny?: string[] }) + : undefined; const cleanup = params.cleanup === "keep" || params.cleanup === "delete" ? (params.cleanup as "keep" | "delete") @@ -200,6 +226,23 @@ export function createSessionsSpawnTool(opts?: { modelWarning = messageText; } } + if (toolPolicy && (toolPolicy.allow || toolPolicy.deny)) { + try { + await callGateway({ + method: "sessions.patch", + params: { key: childSessionKey, spawnToolPolicy: toolPolicy }, + timeoutMs: 10_000, + }); + } catch (err) { + const messageText = + err instanceof Error ? err.message : typeof err === "string" ? err : "error"; + return jsonResult({ + status: "error", + error: `Failed to apply toolPolicy: ${messageText}`, + childSessionKey, + }); + } + } const childSystemPrompt = buildSubagentSystemPrompt({ requesterSessionKey, requesterOrigin, diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 0e745557c..b0c1d8e8b 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -418,6 +418,7 @@ export async function agentCommand( groupChannel: runContext.groupChannel, groupSpace: runContext.groupSpace, spawnedBy, + spawnToolPolicy: sessionEntry?.spawnToolPolicy, currentChannelId: runContext.currentChannelId, currentThreadTs: runContext.currentThreadTs, replyToMode: runContext.replyToMode, diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 48ce428c1..fb55fa7da 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -36,6 +36,8 @@ export type SessionEntry = { sessionFile?: string; /** Parent session key that spawned this session (used for sandbox session-tool scoping). */ spawnedBy?: string; + /** Per-spawn tool policy override. Set by sessions_spawn to restrict child session tools. */ + spawnToolPolicy?: { allow?: string[]; deny?: string[] }; systemSent?: boolean; abortedLastRun?: boolean; chatType?: SessionChatType; diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index 67156a5de..e0cd89c11 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -72,6 +72,15 @@ export const SessionsPatchParamsSchema = Type.Object( execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + spawnToolPolicy: Type.Optional( + Type.Union([ + Type.Object({ + allow: Type.Optional(Type.Array(Type.String())), + deny: Type.Optional(Type.Array(Type.String())), + }), + Type.Null(), + ]), + ), sendPolicy: Type.Optional( Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]), ), diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index d159d1f78..401eedc1f 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -247,6 +247,7 @@ export const agentHandlers: GatewayRequestHandlers = { providerOverride: entry?.providerOverride, label: labelValue, spawnedBy: spawnedByValue, + spawnToolPolicy: entry?.spawnToolPolicy, channel: entry?.channel ?? request.channel?.trim(), groupId: resolvedGroupId ?? entry?.groupId, groupChannel: resolvedGroupChannel ?? entry?.groupChannel, diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index 46b5e7c40..d7e837164 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -90,6 +90,30 @@ export async function applySessionsPatchToStore(params: { } } + if ("spawnToolPolicy" in patch) { + const raw = patch.spawnToolPolicy; + if (raw === null) { + delete next.spawnToolPolicy; + } else if (raw !== undefined) { + if (!isSubagentSessionKey(storeKey)) { + return invalid("spawnToolPolicy is only supported for subagent:* sessions"); + } + if (existing?.spawnToolPolicy) { + return invalid("spawnToolPolicy cannot be changed once set"); + } + const policy: { allow?: string[]; deny?: string[] } = {}; + if (Array.isArray(raw.allow) && raw.allow.length > 0) { + policy.allow = raw.allow.filter((v: unknown) => typeof v === "string" && v.trim()).map((v: string) => v.trim()); + } + if (Array.isArray(raw.deny) && raw.deny.length > 0) { + policy.deny = raw.deny.filter((v: unknown) => typeof v === "string" && v.trim()).map((v: string) => v.trim()); + } + if (policy.allow || policy.deny) { + next.spawnToolPolicy = policy; + } + } + } + if ("label" in patch) { const raw = patch.label; if (raw === null) { diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index bf05e2822..4f6a68eaa 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -17,6 +17,7 @@ import { } from "../agents/tool-policy.js"; import { loadConfig } from "../config/config.js"; import { resolveMainSessionKey } from "../config/sessions.js"; +import { loadSessionEntry } from "./session-utils.js"; import { logWarn } from "../logger.js"; import { isTestDefaultMemorySlotDisabled } from "../plugins/config-state.js"; import { getPluginToolMeta } from "../plugins/tools.js"; @@ -189,9 +190,28 @@ export async function handleToolsInvokeHttpRequest( messageProvider: messageChannel ?? undefined, accountId: accountId ?? null, }); - const subagentPolicy = isSubagentSessionKey(sessionKey) + const baseSubagentPolicy = isSubagentSessionKey(sessionKey) ? resolveSubagentToolPolicy(cfg) : undefined; + const subagentPolicy = (() => { + if (!baseSubagentPolicy) return undefined; + let spawnPolicy: { allow?: string[]; deny?: string[] } | undefined; + try { + spawnPolicy = loadSessionEntry(sessionKey)?.entry?.spawnToolPolicy; + } catch { + spawnPolicy = undefined; + } + if (!spawnPolicy) return baseSubagentPolicy; + const mergedDeny = [ + ...(baseSubagentPolicy.deny ?? []), + ...(spawnPolicy.deny ?? []), + ]; + const mergedAllow = spawnPolicy.allow ? spawnPolicy.allow : baseSubagentPolicy.allow; + return { + allow: mergedAllow, + deny: mergedDeny.length > 0 ? mergedDeny : undefined, + }; + })(); // Build tool list (core + plugin tools). const allTools = createMoltbotTools({