Skip to content

Commit e3796d1

Browse files
authored
fix(core): prevent subagent bypass in plan mode (#18484)
1 parent ee68a10 commit e3796d1

7 files changed

Lines changed: 120 additions & 45 deletions

File tree

packages/cli/src/config/policy-engine.integration.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -434,8 +434,8 @@ describe('Policy Engine Integration Tests', () => {
434434
expect(mcpServerRule?.priority).toBe(2.1); // MCP allowed server
435435

436436
const readOnlyToolRule = rules.find((r) => r.toolName === 'glob');
437-
// Priority 50 in default tier → 1.05
438-
expect(readOnlyToolRule?.priority).toBeCloseTo(1.05, 5);
437+
// Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny)
438+
expect(readOnlyToolRule?.priority).toBeCloseTo(1.07, 5);
439439

440440
// Verify the engine applies these priorities correctly
441441
expect(
@@ -590,8 +590,8 @@ describe('Policy Engine Integration Tests', () => {
590590
expect(server1Rule?.priority).toBe(2.1); // Allowed servers (user tier)
591591

592592
const globRule = rules.find((r) => r.toolName === 'glob');
593-
// Priority 50 in default tier → 1.05
594-
expect(globRule?.priority).toBeCloseTo(1.05, 5); // Auto-accept read-only
593+
// Priority 70 in default tier → 1.07
594+
expect(globRule?.priority).toBeCloseTo(1.07, 5); // Auto-accept read-only
595595

596596
// The PolicyEngine will sort these by priority when it's created
597597
const engine = new PolicyEngine(config);

packages/core/src/agents/registry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
type ModelConfig,
2222
ModelConfigService,
2323
} from '../services/modelConfigService.js';
24-
import { PolicyDecision } from '../policy/types.js';
24+
import { PolicyDecision, PRIORITY_SUBAGENT_TOOL } from '../policy/types.js';
2525

2626
/**
2727
* Returns the model config alias for a given agent definition.
@@ -297,7 +297,7 @@ export class AgentRegistry {
297297
definition.kind === 'local'
298298
? PolicyDecision.ALLOW
299299
: PolicyDecision.ASK_USER,
300-
priority: 1.05,
300+
priority: PRIORITY_SUBAGENT_TOOL,
301301
source: 'AgentRegistry (Dynamic)',
302302
});
303303
}

packages/core/src/policy/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ export async function createPolicyEngineConfig(
194194
// 10: Write tools default to ASK_USER (becomes 1.010 in default tier)
195195
// 15: Auto-edit tool override (becomes 1.015 in default tier)
196196
// 50: Read-only tools (becomes 1.050 in default tier)
197+
// 60: Plan mode catch-all DENY override (becomes 1.060 in default tier)
198+
// 70: Plan mode explicit ALLOW override (becomes 1.070 in default tier)
197199
// 999: YOLO mode allow-all (becomes 1.999 in default tier)
198200

199201
// MCP servers that are explicitly excluded in settings.mcp.excluded

packages/core/src/policy/policies/plan.toml

Lines changed: 8 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -21,66 +21,36 @@
2121
#
2222
# TOML policy priorities (before transformation):
2323
# 10: Write tools default to ASK_USER (becomes 1.010 in default tier)
24-
# 20: Plan mode catch-all DENY override (becomes 1.020 in default tier)
25-
# 50: Read-only tools (becomes 1.050 in default tier)
24+
# 60: Plan mode catch-all DENY override (becomes 1.060 in default tier)
25+
# 70: Plan mode explicit ALLOW override (becomes 1.070 in default tier)
2626
# 999: YOLO mode allow-all (becomes 1.999 in default tier)
2727

2828
# Catch-All: Deny everything by default in Plan mode.
2929

3030
[[rule]]
3131
decision = "deny"
32-
priority = 20
32+
priority = 60
3333
modes = ["plan"]
3434
deny_message = "You are in Plan Mode - adjust your prompt to only use read and search tools."
3535

3636
# Explicitly Allow Read-Only Tools in Plan mode.
3737

3838
[[rule]]
39-
toolName = "glob"
39+
toolName = ["glob", "grep_search", "list_directory", "read_file", "google_web_search"]
4040
decision = "allow"
41-
priority = 50
41+
priority = 70
4242
modes = ["plan"]
4343

4444
[[rule]]
45-
toolName = "grep_search"
46-
decision = "allow"
47-
priority = 50
48-
modes = ["plan"]
49-
50-
[[rule]]
51-
toolName = "list_directory"
52-
decision = "allow"
53-
priority = 50
54-
modes = ["plan"]
55-
56-
[[rule]]
57-
toolName = "read_file"
58-
decision = "allow"
59-
priority = 50
60-
modes = ["plan"]
61-
62-
[[rule]]
63-
toolName = "google_web_search"
64-
decision = "allow"
65-
priority = 50
66-
modes = ["plan"]
67-
68-
[[rule]]
69-
toolName = "ask_user"
70-
decision = "ask_user"
71-
priority = 50
72-
modes = ["plan"]
73-
74-
[[rule]]
75-
toolName = "exit_plan_mode"
45+
toolName = ["ask_user", "exit_plan_mode"]
7646
decision = "ask_user"
77-
priority = 50
47+
priority = 70
7848
modes = ["plan"]
7949

8050
# Allow write_file and replace for .md files in plans directory
8151
[[rule]]
8252
toolName = ["write_file", "replace"]
8353
decision = "allow"
84-
priority = 50
54+
priority = 70
8555
modes = ["plan"]
8656
argsPattern = "\"file_path\":\"[^\"]+/\\.gemini/tmp/[a-zA-Z0-9_-]+/plans/[a-zA-Z0-9_-]+\\.md\""

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
type SafetyCheckerRule,
1414
InProcessCheckerType,
1515
ApprovalMode,
16+
PRIORITY_SUBAGENT_TOOL,
1617
} from './types.js';
1718
import type { FunctionCall } from '@google/genai';
1819
import { SafetyCheckDecision } from '../safety/protocol.js';
@@ -1481,6 +1482,37 @@ describe('PolicyEngine', () => {
14811482
});
14821483
});
14831484

1485+
describe('Plan Mode vs Subagent Priority (Regression)', () => {
1486+
it('should DENY subagents in Plan Mode despite dynamic allow rules', async () => {
1487+
// Plan Mode Deny (1.06) > Subagent Allow (1.05)
1488+
1489+
const fixedRules: PolicyRule[] = [
1490+
{
1491+
decision: PolicyDecision.DENY,
1492+
priority: 1.06,
1493+
modes: [ApprovalMode.PLAN],
1494+
},
1495+
{
1496+
toolName: 'codebase_investigator',
1497+
decision: PolicyDecision.ALLOW,
1498+
priority: PRIORITY_SUBAGENT_TOOL,
1499+
},
1500+
];
1501+
1502+
const fixedEngine = new PolicyEngine({
1503+
rules: fixedRules,
1504+
approvalMode: ApprovalMode.PLAN,
1505+
});
1506+
1507+
const fixedResult = await fixedEngine.check(
1508+
{ name: 'codebase_investigator' },
1509+
undefined,
1510+
);
1511+
1512+
expect(fixedResult.decision).toBe(PolicyDecision.DENY);
1513+
});
1514+
});
1515+
14841516
describe('shell command parsing failure', () => {
14851517
it('should return ALLOW in YOLO mode even if shell command parsing fails', async () => {
14861518
const { splitCommands } = await import('../utils/shell-utils.js');

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

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,21 @@
55
*/
66

77
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
8-
import { PolicyDecision } from './types.js';
8+
import {
9+
PolicyDecision,
10+
ApprovalMode,
11+
PRIORITY_SUBAGENT_TOOL,
12+
} from './types.js';
913
import * as fs from 'node:fs/promises';
1014
import * as path from 'node:path';
1115
import * as os from 'node:os';
16+
import { fileURLToPath } from 'node:url';
1217
import { loadPoliciesFromToml } from './toml-loader.js';
1318
import type { PolicyLoadResult } from './toml-loader.js';
19+
import { PolicyEngine } from './policy-engine.js';
20+
21+
const __filename = fileURLToPath(import.meta.url);
22+
const __dirname = path.dirname(__filename);
1423

1524
describe('policy-toml-loader', () => {
1625
let tempDir: string;
@@ -500,4 +509,60 @@ priority = 100
500509
expect(error.message).toContain('Failed to read policy directory');
501510
});
502511
});
512+
513+
describe('Built-in Plan Mode Policy', () => {
514+
it('should override default subagent rules when in Plan Mode', async () => {
515+
const planTomlPath = path.resolve(__dirname, 'policies', 'plan.toml');
516+
const fileContent = await fs.readFile(planTomlPath, 'utf-8');
517+
const tempPolicyDir = await fs.mkdtemp(
518+
path.join(os.tmpdir(), 'plan-policy-test-'),
519+
);
520+
try {
521+
await fs.writeFile(path.join(tempPolicyDir, 'plan.toml'), fileContent);
522+
const getPolicyTier = () => 1; // Default tier
523+
524+
// 1. Load the actual Plan Mode policies
525+
const result = await loadPoliciesFromToml(
526+
[tempPolicyDir],
527+
getPolicyTier,
528+
);
529+
530+
// 2. Initialize Policy Engine with these rules
531+
const engine = new PolicyEngine({
532+
rules: result.rules,
533+
approvalMode: ApprovalMode.PLAN,
534+
});
535+
536+
// 3. Simulate a Subagent being registered (Dynamic Rule)
537+
engine.addRule({
538+
toolName: 'codebase_investigator',
539+
decision: PolicyDecision.ALLOW,
540+
priority: PRIORITY_SUBAGENT_TOOL,
541+
source: 'AgentRegistry (Dynamic)',
542+
});
543+
544+
// 4. Verify Behavior:
545+
// The Plan Mode "Catch-All Deny" (from plan.toml) should override the Subagent Allow
546+
const checkResult = await engine.check(
547+
{ name: 'codebase_investigator' },
548+
undefined,
549+
);
550+
551+
expect(
552+
checkResult.decision,
553+
'Subagent should be DENIED in Plan Mode',
554+
).toBe(PolicyDecision.DENY);
555+
556+
// 5. Verify Explicit Allows still work
557+
// e.g. 'read_file' should be allowed because its priority in plan.toml (70) is higher than the deny (60)
558+
const readResult = await engine.check({ name: 'read_file' }, undefined);
559+
expect(
560+
readResult.decision,
561+
'Explicitly allowed tools (read_file) should be ALLOWED in Plan Mode',
562+
).toBe(PolicyDecision.ALLOW);
563+
} finally {
564+
await fs.rm(tempPolicyDir, { recursive: true, force: true });
565+
}
566+
});
567+
});
503568
});

packages/core/src/policy/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,3 +276,9 @@ export interface CheckResult {
276276
decision: PolicyDecision;
277277
rule?: PolicyRule;
278278
}
279+
280+
/**
281+
* Priority for subagent tools (registered dynamically).
282+
* Effective priority matching Tier 1 (Default) read-only tools.
283+
*/
284+
export const PRIORITY_SUBAGENT_TOOL = 1.05;

0 commit comments

Comments
 (0)