Skip to content

Commit 5241174

Browse files
authored
feat(plan): enforce strict read-only policy and halt execution on violation (google-gemini#16849)
1 parent 013a4e0 commit 5241174

4 files changed

Lines changed: 179 additions & 8 deletions

File tree

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,43 @@ describe('Policy Engine Integration Tests', () => {
287287
).toBe(PolicyDecision.ASK_USER);
288288
});
289289

290+
it('should handle Plan mode correctly', async () => {
291+
const settings: Settings = {};
292+
293+
const config = await createPolicyEngineConfig(
294+
settings,
295+
ApprovalMode.PLAN,
296+
);
297+
const engine = new PolicyEngine(config);
298+
299+
// Read and search tools should be allowed
300+
expect(
301+
(await engine.check({ name: 'read_file' }, undefined)).decision,
302+
).toBe(PolicyDecision.ALLOW);
303+
expect(
304+
(await engine.check({ name: 'google_web_search' }, undefined)).decision,
305+
).toBe(PolicyDecision.ALLOW);
306+
expect(
307+
(await engine.check({ name: 'list_directory' }, undefined)).decision,
308+
).toBe(PolicyDecision.ALLOW);
309+
310+
// Other tools should be denied via catch all
311+
expect(
312+
(await engine.check({ name: 'replace' }, undefined)).decision,
313+
).toBe(PolicyDecision.DENY);
314+
expect(
315+
(await engine.check({ name: 'write_file' }, undefined)).decision,
316+
).toBe(PolicyDecision.DENY);
317+
expect(
318+
(await engine.check({ name: 'run_shell_command' }, undefined)).decision,
319+
).toBe(PolicyDecision.DENY);
320+
321+
// Unknown tools should be denied via catch-all
322+
expect(
323+
(await engine.check({ name: 'unknown_tool' }, undefined)).decision,
324+
).toBe(PolicyDecision.DENY);
325+
});
326+
290327
it('should verify priority ordering works correctly in practice', async () => {
291328
const settings: Settings = {
292329
tools: {

packages/core/src/core/coreToolScheduler.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
import { describe, it, expect, vi } from 'vitest';
88
import type { Mock } from 'vitest';
99
import type { CallableTool } from '@google/genai';
10-
import { CoreToolScheduler } from './coreToolScheduler.js';
10+
import {
11+
CoreToolScheduler,
12+
PLAN_MODE_DENIAL_MESSAGE,
13+
} from './coreToolScheduler.js';
1114
import type {
1215
ToolCall,
1316
WaitingToolCall,
@@ -32,6 +35,7 @@ import {
3235
ApprovalMode,
3336
HookSystem,
3437
PolicyDecision,
38+
ToolErrorType,
3539
} from '../index.js';
3640
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
3741
import {
@@ -2078,4 +2082,53 @@ describe('CoreToolScheduler Sequential Execution', () => {
20782082

20792083
expect(onAllToolCallsComplete).toHaveBeenCalledTimes(1);
20802084
});
2085+
2086+
describe('Policy Decisions in Plan Mode', () => {
2087+
it('should return STOP_EXECUTION error type and informative message when denied in Plan Mode', async () => {
2088+
const mockTool = new MockTool({
2089+
name: 'dangerous_tool',
2090+
displayName: 'Dangerous Tool',
2091+
description: 'Does risky stuff',
2092+
});
2093+
const mockToolRegistry = {
2094+
getTool: () => mockTool,
2095+
getAllToolNames: () => ['dangerous_tool'],
2096+
} as unknown as ToolRegistry;
2097+
2098+
const onAllToolCallsComplete = vi.fn();
2099+
2100+
const mockConfig = createMockConfig({
2101+
getToolRegistry: () => mockToolRegistry,
2102+
getApprovalMode: () => ApprovalMode.PLAN,
2103+
getPolicyEngine: () =>
2104+
({
2105+
check: async () => ({ decision: PolicyDecision.DENY }),
2106+
}) as unknown as PolicyEngine,
2107+
});
2108+
2109+
const scheduler = new CoreToolScheduler({
2110+
config: mockConfig,
2111+
onAllToolCallsComplete,
2112+
getPreferredEditor: () => 'vscode',
2113+
});
2114+
2115+
const request = {
2116+
callId: 'call-1',
2117+
name: 'dangerous_tool',
2118+
args: {},
2119+
isClientInitiated: false,
2120+
prompt_id: 'prompt-1',
2121+
};
2122+
2123+
await scheduler.schedule(request, new AbortController().signal);
2124+
2125+
expect(onAllToolCallsComplete).toHaveBeenCalledTimes(1);
2126+
const reportedTools = onAllToolCallsComplete.mock.calls[0][0];
2127+
const result = reportedTools[0];
2128+
2129+
expect(result.status).toBe('error');
2130+
expect(result.response.errorType).toBe(ToolErrorType.STOP_EXECUTION);
2131+
expect(result.response.error.message).toBe(PLAN_MODE_DENIAL_MESSAGE);
2132+
});
2133+
});
20812134
});

packages/core/src/core/coreToolScheduler.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
} from '../tools/tools.js';
1515
import type { EditorType } from '../utils/editor.js';
1616
import type { Config } from '../config/config.js';
17-
import { PolicyDecision } from '../policy/types.js';
17+
import { PolicyDecision, ApprovalMode } from '../policy/types.js';
1818
import { logToolCall } from '../telemetry/loggers.js';
1919
import { ToolErrorType } from '../tools/tool-error.js';
2020
import { ToolCallEvent } from '../telemetry/types.js';
@@ -65,6 +65,9 @@ export type {
6565
ToolCallResponseInfo,
6666
};
6767

68+
export const PLAN_MODE_DENIAL_MESSAGE =
69+
'You are in Plan Mode - adjust your prompt to only use read and search tools.';
70+
6871
const createErrorResponse = (
6972
request: ToolCallRequestInfo,
7073
error: Error,
@@ -603,16 +606,18 @@ export class CoreToolScheduler {
603606
.check(toolCallForPolicy, serverName);
604607

605608
if (decision === PolicyDecision.DENY) {
606-
const errorMessage = `Tool execution denied by policy.`;
609+
let errorMessage = `Tool execution denied by policy.`;
610+
let errorType = ToolErrorType.POLICY_VIOLATION;
611+
612+
if (this.config.getApprovalMode() === ApprovalMode.PLAN) {
613+
errorMessage = PLAN_MODE_DENIAL_MESSAGE;
614+
errorType = ToolErrorType.STOP_EXECUTION;
615+
}
607616
this.setStatusInternal(
608617
reqInfo.callId,
609618
'error',
610619
signal,
611-
createErrorResponse(
612-
reqInfo,
613-
new Error(errorMessage),
614-
ToolErrorType.POLICY_VIOLATION,
615-
),
620+
createErrorResponse(reqInfo, new Error(errorMessage), errorType),
616621
);
617622
await this.checkAndNotifyCompletion(signal);
618623
return;
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Priority system for policy rules:
2+
# - Higher priority numbers win over lower priority numbers
3+
# - When multiple rules match, the highest priority rule is applied
4+
# - Rules are evaluated in order of priority (highest first)
5+
#
6+
# Priority bands (tiers):
7+
# - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100)
8+
# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100)
9+
# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
10+
#
11+
# This ensures Admin > User > Default hierarchy is always preserved,
12+
# while allowing user-specified priorities to work within each tier.
13+
#
14+
# Settings-based and dynamic rules (all in user tier 2.x):
15+
# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI
16+
# 2.9: MCP servers excluded list (security: persistent server blocks)
17+
# 2.4: Command line flag --exclude-tools (explicit temporary blocks)
18+
# 2.3: Command line flag --allowed-tools (explicit temporary allows)
19+
# 2.2: MCP servers with trust=true (persistent trusted servers)
20+
# 2.1: MCP servers allowed list (persistent general server allows)
21+
#
22+
# TOML policy priorities (before transformation):
23+
# 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)
26+
# 999: YOLO mode allow-all (becomes 1.999 in default tier)
27+
28+
# Catch-All: Deny everything by default in Plan mode.
29+
30+
[[rule]]
31+
decision = "deny"
32+
priority = 20
33+
modes = ["plan"]
34+
35+
# Explicitly Allow Read-Only Tools in Plan mode.
36+
37+
[[rule]]
38+
toolName = "glob"
39+
decision = "allow"
40+
priority = 50
41+
modes = ["plan"]
42+
43+
[[rule]]
44+
toolName = "search_file_content"
45+
decision = "allow"
46+
priority = 50
47+
modes = ["plan"]
48+
49+
[[rule]]
50+
toolName = "list_directory"
51+
decision = "allow"
52+
priority = 50
53+
modes = ["plan"]
54+
55+
[[rule]]
56+
toolName = "read_file"
57+
decision = "allow"
58+
priority = 50
59+
modes = ["plan"]
60+
61+
[[rule]]
62+
toolName = "read_many_files"
63+
decision = "allow"
64+
priority = 50
65+
modes = ["plan"]
66+
67+
[[rule]]
68+
toolName = "google_web_search"
69+
decision = "allow"
70+
priority = 50
71+
modes = ["plan"]
72+
73+
[[rule]]
74+
toolName = "SubagentInvocation"
75+
decision = "allow"
76+
priority = 50

0 commit comments

Comments
 (0)