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,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
spawnToolPolicy: params.spawnToolPolicy,
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
replyToMode: params.replyToMode,

View File

@ -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,

View File

@ -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;

View File

@ -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;

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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;

View File

@ -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()]),
),

View File

@ -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,

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

View File

@ -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({