Skip to content

Commit e4ef2ac

Browse files
akh64bityashodipmore
authored andcommitted
feat(policy): support subagent-specific policies in TOML (#21431)
1 parent fc9bbc2 commit e4ef2ac

File tree

10 files changed

+56
-3
lines changed

10 files changed

+56
-3
lines changed

docs/reference/policy-engine.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,10 @@ Here is a breakdown of the fields available in a TOML policy rule:
219219
# A unique name for the tool, or an array of names.
220220
toolName = "run_shell_command"
221221
222+
# (Optional) The name of a subagent. If provided, the rule only applies to tool calls
223+
# made by this specific subagent.
224+
subagent = "generalist"
225+
222226
# (Optional) The name of an MCP server. Can be combined with toolName
223227
# to form a composite name like "mcpName__toolName".
224228
mcpName = "my-custom-server"

packages/core/src/agents/agent-scheduler.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ describe('agent-scheduler', () => {
2727
mockMessageBus = {} as Mocked<MessageBus>;
2828
mockToolRegistry = {
2929
getTool: vi.fn(),
30+
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
3031
} as unknown as Mocked<ToolRegistry>;
3132
mockConfig = {
3233
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),

packages/core/src/agents/agent-scheduler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,11 @@ export async function scheduleAgentTools(
5757
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
5858
const agentConfig: Config = Object.create(config);
5959
agentConfig.getToolRegistry = () => toolRegistry;
60+
agentConfig.getMessageBus = () => toolRegistry.getMessageBus();
6061

6162
const scheduler = new Scheduler({
6263
config: agentConfig,
63-
messageBus: config.getMessageBus(),
64+
messageBus: toolRegistry.getMessageBus(),
6465
getPreferredEditor: getPreferredEditor ?? (() => undefined),
6566
schedulerId,
6667
parentCallId,

packages/core/src/agents/local-executor.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { ToolRegistry } from '../tools/tool-registry.js';
1919
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
2020
import { CompressionStatus } from '../core/turn.js';
2121
import { type ToolCallRequestInfo } from '../scheduler/types.js';
22+
import { type Message } from '../confirmation-bus/types.js';
2223
import { ChatCompressionService } from '../services/chatCompressionService.js';
2324
import { getDirectoryContextString } from '../utils/environmentContext.js';
2425
import { promptIdContext } from '../utils/promptIdContext.js';
@@ -113,10 +114,27 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
113114
runtimeContext: Config,
114115
onActivity?: ActivityCallback,
115116
): Promise<LocalAgentExecutor<TOutput>> {
117+
const parentMessageBus = runtimeContext.getMessageBus();
118+
119+
// Create an override object to inject the subagent name into tool confirmation requests
120+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
121+
const subagentMessageBus = Object.create(
122+
parentMessageBus,
123+
) as typeof parentMessageBus;
124+
subagentMessageBus.publish = async (message: Message) => {
125+
if (message.type === 'tool-confirmation-request') {
126+
return parentMessageBus.publish({
127+
...message,
128+
subagent: definition.name,
129+
});
130+
}
131+
return parentMessageBus.publish(message);
132+
};
133+
116134
// Create an isolated tool registry for this agent instance.
117135
const agentToolRegistry = new ToolRegistry(
118136
runtimeContext,
119-
runtimeContext.getMessageBus(),
137+
subagentMessageBus,
120138
);
121139
const parentToolRegistry = runtimeContext.getToolRegistry();
122140
const allAgentNames = new Set(

packages/core/src/confirmation-bus/message-bus.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ describe('MessageBus', () => {
160160
{ name: 'test-tool', args: {} },
161161
'test-server',
162162
annotations,
163+
undefined,
163164
);
164165
});
165166

packages/core/src/confirmation-bus/message-bus.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export class MessageBus extends EventEmitter {
5656
message.toolCall,
5757
message.serverName,
5858
message.toolAnnotations,
59+
message.subagent,
5960
);
6061

6162
switch (decision) {

packages/core/src/confirmation-bus/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ export interface ToolConfirmationRequest {
3838
* Optional tool annotations (e.g., readOnlyHint, destructiveHint) from MCP.
3939
*/
4040
toolAnnotations?: Record<string, unknown>;
41+
/**
42+
* Optional subagent name, if this tool call was initiated by a subagent.
43+
*/
44+
subagent?: string;
4145
/**
4246
* Optional rich details for the confirmation UI (diffs, counts, etc.)
4347
*/

packages/core/src/policy/policy-engine.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ function ruleMatches(
7474
serverName: string | undefined,
7575
currentApprovalMode: ApprovalMode,
7676
toolAnnotations?: Record<string, unknown>,
77+
subagent?: string,
7778
): boolean {
7879
// Check if rule applies to current approval mode
7980
if (rule.modes && rule.modes.length > 0) {
@@ -82,6 +83,13 @@ function ruleMatches(
8283
}
8384
}
8485

86+
// Check subagent if specified (only for PolicyRule, SafetyCheckerRule doesn't have it)
87+
if ('subagent' in rule && rule.subagent) {
88+
if (rule.subagent !== subagent) {
89+
return false;
90+
}
91+
}
92+
8593
// Strictly enforce mcpName identity if the rule dictates it
8694
if (rule.mcpName) {
8795
if (rule.mcpName === '*') {
@@ -203,6 +211,7 @@ export class PolicyEngine {
203211
allowRedirection?: boolean,
204212
rule?: PolicyRule,
205213
toolAnnotations?: Record<string, unknown>,
214+
subagent?: string,
206215
): Promise<CheckResult> {
207216
if (!command) {
208217
return {
@@ -294,6 +303,7 @@ export class PolicyEngine {
294303
{ name: toolName, args: { command: subCmd, dir_path } },
295304
serverName,
296305
toolAnnotations,
306+
subagent,
297307
);
298308

299309
// subResult.decision is already filtered through applyNonInteractiveMode by this.check()
@@ -352,6 +362,7 @@ export class PolicyEngine {
352362
toolCall: FunctionCall,
353363
serverName: string | undefined,
354364
toolAnnotations?: Record<string, unknown>,
365+
subagent?: string,
355366
): Promise<CheckResult> {
356367
// Case 1: Metadata injection is the primary and safest way to identify an MCP server.
357368
// If we have explicit `_serverName` metadata (usually injected by tool-registry for active tools), use it.
@@ -419,6 +430,7 @@ export class PolicyEngine {
419430
serverName,
420431
this.approvalMode,
421432
toolAnnotations,
433+
subagent,
422434
),
423435
);
424436

@@ -437,6 +449,7 @@ export class PolicyEngine {
437449
rule.allowRedirection,
438450
rule,
439451
toolAnnotations,
452+
subagent,
440453
);
441454
decision = shellResult.decision;
442455
if (shellResult.rule) {
@@ -463,9 +476,10 @@ export class PolicyEngine {
463476
this.defaultDecision,
464477
serverName,
465478
shellDirPath,
466-
undefined,
479+
false,
467480
undefined,
468481
toolAnnotations,
482+
subagent,
469483
);
470484
decision = shellResult.decision;
471485
matchedRule = shellResult.rule;
@@ -485,6 +499,7 @@ export class PolicyEngine {
485499
serverName,
486500
this.approvalMode,
487501
toolAnnotations,
502+
subagent,
488503
)
489504
) {
490505
debugLogger.debug(

packages/core/src/policy/toml-loader.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const MAX_TYPO_DISTANCE = 3;
3838
*/
3939
const PolicyRuleSchema = z.object({
4040
toolName: z.union([z.string(), z.array(z.string())]).optional(),
41+
subagent: z.string().optional(),
4142
mcpName: z.string().optional(),
4243
argsPattern: z.string().optional(),
4344
commandPrefix: z.union([z.string(), z.array(z.string())]).optional(),
@@ -464,6 +465,7 @@ export async function loadPoliciesFromToml(
464465

465466
const policyRule: PolicyRule = {
466467
toolName: effectiveToolName,
468+
subagent: rule.subagent,
467469
mcpName: rule.mcpName,
468470
decision: rule.decision,
469471
priority: transformPriority(rule.priority, tier),

packages/core/src/policy/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,12 @@ export interface PolicyRule {
110110
*/
111111
toolName?: string;
112112

113+
/**
114+
* The name of the subagent this rule applies to.
115+
* If undefined, the rule applies regardless of whether it's the main agent or a subagent.
116+
*/
117+
subagent?: string;
118+
113119
/**
114120
* Identifies the MCP server this rule applies to.
115121
* Enables precise rule matching against `serverName` metadata instead

0 commit comments

Comments
 (0)