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
This commit is contained in:
Gustav Botichelli 2026-01-29 22:28:42 -05:00
parent 4583f88626
commit a55b4dcb7e
12 changed files with 127 additions and 2 deletions

View File

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

View File

@ -215,6 +215,7 @@ export async function runEmbeddedAttempt(
groupChannel: params.groupChannel, groupChannel: params.groupChannel,
groupSpace: params.groupSpace, groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy, spawnedBy: params.spawnedBy,
spawnToolPolicy: params.spawnToolPolicy,
senderId: params.senderId, senderId: params.senderId,
senderName: params.senderName, senderName: params.senderName,
senderUsername: params.senderUsername, senderUsername: params.senderUsername,

View File

@ -35,6 +35,8 @@ export type RunEmbeddedPiAgentParams = {
groupSpace?: string | null; groupSpace?: string | null;
/** Parent session key for subagent policy inheritance. */ /** Parent session key for subagent policy inheritance. */
spawnedBy?: string | null; spawnedBy?: string | null;
/** Per-spawn tool policy override from sessions_spawn toolPolicy param. */
spawnToolPolicy?: { allow?: string[]; deny?: string[] };
senderId?: string | null; senderId?: string | null;
senderName?: string | null; senderName?: string | null;
senderUsername?: string | null; senderUsername?: string | null;

View File

@ -31,6 +31,8 @@ export type EmbeddedRunAttemptParams = {
groupSpace?: string | null; groupSpace?: string | null;
/** Parent session key for subagent policy inheritance. */ /** Parent session key for subagent policy inheritance. */
spawnedBy?: string | null; spawnedBy?: string | null;
/** Per-spawn tool policy override from sessions_spawn toolPolicy param. */
spawnToolPolicy?: { allow?: string[]; deny?: string[] };
senderId?: string | null; senderId?: string | null;
senderName?: string | null; senderName?: string | null;
senderUsername?: string | null; senderUsername?: string | null;

View File

@ -150,6 +150,8 @@ export function createMoltbotCodingTools(options?: {
hasRepliedRef?: { value: boolean }; hasRepliedRef?: { value: boolean };
/** If true, the model has native vision capability */ /** If true, the model has native vision capability */
modelHasVision?: boolean; modelHasVision?: boolean;
/** Per-spawn tool policy override from sessions_spawn toolPolicy param. */
spawnToolPolicy?: { allow?: string[]; deny?: string[] };
}): AnyAgentTool[] { }): AnyAgentTool[] {
const execToolName = "exec"; const execToolName = "exec";
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined; const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
@ -197,10 +199,27 @@ export function createMoltbotCodingTools(options?: {
providerProfileAlsoAllow, providerProfileAlsoAllow,
); );
const scopeKey = options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined); const scopeKey = options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined);
const subagentPolicy = const baseSubagentPolicy =
isSubagentSessionKey(options?.sessionKey) && options?.sessionKey isSubagentSessionKey(options?.sessionKey) && options?.sessionKey
? resolveSubagentToolPolicy(options.config) ? resolveSubagentToolPolicy(options.config)
: undefined; : 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", [ const allowBackground = isToolAllowedByPolicies("process", [
profilePolicyWithAlsoAllow, profilePolicyWithAlsoAllow,
providerProfilePolicyWithAlsoAllow, providerProfilePolicyWithAlsoAllow,

View File

@ -31,6 +31,28 @@ const SessionsSpawnToolSchema = Type.Object({
agentId: Type.Optional(Type.String()), agentId: Type.Optional(Type.String()),
model: Type.Optional(Type.String()), model: Type.Optional(Type.String()),
thinking: 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 })), runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
// Back-compat alias. Prefer runTimeoutSeconds. // Back-compat alias. Prefer runTimeoutSeconds.
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
@ -83,6 +105,10 @@ export function createSessionsSpawnTool(opts?: {
const requestedAgentId = readStringParam(params, "agentId"); const requestedAgentId = readStringParam(params, "agentId");
const modelOverride = readStringParam(params, "model"); const modelOverride = readStringParam(params, "model");
const thinkingOverrideRaw = readStringParam(params, "thinking"); const thinkingOverrideRaw = readStringParam(params, "thinking");
const toolPolicy =
params.toolPolicy && typeof params.toolPolicy === "object"
? (params.toolPolicy as { allow?: string[]; deny?: string[] })
: undefined;
const cleanup = const cleanup =
params.cleanup === "keep" || params.cleanup === "delete" params.cleanup === "keep" || params.cleanup === "delete"
? (params.cleanup as "keep" | "delete") ? (params.cleanup as "keep" | "delete")
@ -200,6 +226,23 @@ export function createSessionsSpawnTool(opts?: {
modelWarning = messageText; 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({ const childSystemPrompt = buildSubagentSystemPrompt({
requesterSessionKey, requesterSessionKey,
requesterOrigin, requesterOrigin,

View File

@ -418,6 +418,7 @@ export async function agentCommand(
groupChannel: runContext.groupChannel, groupChannel: runContext.groupChannel,
groupSpace: runContext.groupSpace, groupSpace: runContext.groupSpace,
spawnedBy, spawnedBy,
spawnToolPolicy: sessionEntry?.spawnToolPolicy,
currentChannelId: runContext.currentChannelId, currentChannelId: runContext.currentChannelId,
currentThreadTs: runContext.currentThreadTs, currentThreadTs: runContext.currentThreadTs,
replyToMode: runContext.replyToMode, replyToMode: runContext.replyToMode,

View File

@ -36,6 +36,8 @@ export type SessionEntry = {
sessionFile?: string; sessionFile?: string;
/** Parent session key that spawned this session (used for sandbox session-tool scoping). */ /** Parent session key that spawned this session (used for sandbox session-tool scoping). */
spawnedBy?: string; spawnedBy?: string;
/** Per-spawn tool policy override. Set by sessions_spawn to restrict child session tools. */
spawnToolPolicy?: { allow?: string[]; deny?: string[] };
systemSent?: boolean; systemSent?: boolean;
abortedLastRun?: boolean; abortedLastRun?: boolean;
chatType?: SessionChatType; chatType?: SessionChatType;

View File

@ -72,6 +72,15 @@ export const SessionsPatchParamsSchema = Type.Object(
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
spawnedBy: 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( sendPolicy: Type.Optional(
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]), Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
), ),

View File

@ -247,6 +247,7 @@ export const agentHandlers: GatewayRequestHandlers = {
providerOverride: entry?.providerOverride, providerOverride: entry?.providerOverride,
label: labelValue, label: labelValue,
spawnedBy: spawnedByValue, spawnedBy: spawnedByValue,
spawnToolPolicy: entry?.spawnToolPolicy,
channel: entry?.channel ?? request.channel?.trim(), channel: entry?.channel ?? request.channel?.trim(),
groupId: resolvedGroupId ?? entry?.groupId, groupId: resolvedGroupId ?? entry?.groupId,
groupChannel: resolvedGroupChannel ?? entry?.groupChannel, groupChannel: resolvedGroupChannel ?? entry?.groupChannel,

View File

@ -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) { if ("label" in patch) {
const raw = patch.label; const raw = patch.label;
if (raw === null) { if (raw === null) {

View File

@ -17,6 +17,7 @@ import {
} from "../agents/tool-policy.js"; } from "../agents/tool-policy.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { resolveMainSessionKey } from "../config/sessions.js"; import { resolveMainSessionKey } from "../config/sessions.js";
import { loadSessionEntry } from "./session-utils.js";
import { logWarn } from "../logger.js"; import { logWarn } from "../logger.js";
import { isTestDefaultMemorySlotDisabled } from "../plugins/config-state.js"; import { isTestDefaultMemorySlotDisabled } from "../plugins/config-state.js";
import { getPluginToolMeta } from "../plugins/tools.js"; import { getPluginToolMeta } from "../plugins/tools.js";
@ -189,9 +190,28 @@ export async function handleToolsInvokeHttpRequest(
messageProvider: messageChannel ?? undefined, messageProvider: messageChannel ?? undefined,
accountId: accountId ?? null, accountId: accountId ?? null,
}); });
const subagentPolicy = isSubagentSessionKey(sessionKey) const baseSubagentPolicy = isSubagentSessionKey(sessionKey)
? resolveSubagentToolPolicy(cfg) ? resolveSubagentToolPolicy(cfg)
: undefined; : 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). // Build tool list (core + plugin tools).
const allTools = createMoltbotTools({ const allTools = createMoltbotTools({