From 65d26e73a290c74bf9db1f34b8f9865e25a62392 Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Wed, 11 Feb 2026 12:32:02 -0500 Subject: [PATCH] feat(plan): document and validate Plan Mode policy overrides (#18825) --- docs/cli/plan-mode.md | 50 ++++++++++++ docs/core/policy-engine.md | 16 +++- packages/core/src/policy/config.test.ts | 103 ++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 3 deletions(-) diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 0d6b72206e..105e5aa5e7 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -30,6 +30,7 @@ implementation strategy. - [The Planning Workflow](#the-planning-workflow) - [Exiting Plan Mode](#exiting-plan-mode) - [Tool Restrictions](#tool-restrictions) + - [Customizing Policies](#customizing-policies) ## Starting in Plan Mode @@ -98,6 +99,53 @@ These are the only allowed tools: - **Planning (Write):** [`write_file`] and [`replace`] ONLY allowed for `.md` files in the `~/.gemini/tmp//plans/` directory. +### Customizing Policies + +Plan Mode is designed to be read-only by default to ensure safety during the +research phase. However, you may occasionally need to allow specific tools to +assist in your planning. + +Because user policies (Tier 2) have a higher base priority than built-in +policies (Tier 1), you can override Plan Mode's default restrictions by creating +a rule in your `~/.gemini/policies/` directory. + +#### Example: Allow `git status` and `git diff` in Plan Mode + +This rule allows you to check the repository status and see changes while in +Plan Mode. + +`~/.gemini/policies/git-research.toml` + +```toml +[[rule]] +toolName = "run_shell_command" +commandPrefix = ["git status", "git diff"] +decision = "allow" +priority = 100 +modes = ["plan"] +``` + +#### Example: Enable research sub-agents in Plan Mode + +You can enable [experimental research sub-agents] like `codebase_investigator` +to help gather architecture details during the planning phase. + +`~/.gemini/policies/research-subagents.toml` + +```toml +[[rule]] +toolName = "codebase_investigator" +decision = "allow" +priority = 100 +modes = ["plan"] +``` + +Tell the agent it can use these tools in your prompt, for example: _"You can +check ongoing changes in git."_ + +For more information on how the policy engine works, see the [Policy Engine +Guide]. + [`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder [`read_file`]: /docs/tools/file-system.md#2-read_file-readfile [`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext @@ -106,3 +154,5 @@ These are the only allowed tools: [`google_web_search`]: /docs/tools/web-search.md [`replace`]: /docs/tools/file-system.md#6-replace-edit [MCP tools]: /docs/tools/mcp-server.md +[experimental research sub-agents]: /docs/core/subagents.md +[Policy Engine Guide]: /docs/core/policy-engine.md diff --git a/docs/core/policy-engine.md b/docs/core/policy-engine.md index f09ca01b70..a99a6652d8 100644 --- a/docs/core/policy-engine.md +++ b/docs/core/policy-engine.md @@ -119,9 +119,17 @@ For example: Approval modes allow the policy engine to apply different sets of rules based on the CLI's operational mode. A rule can be associated with one or more modes -(e.g., `yolo`, `autoEdit`). The rule will only be active if the CLI is running -in one of its specified modes. If a rule has no modes specified, it is always -active. +(e.g., `yolo`, `autoEdit`, `plan`). The rule will only be active if the CLI is +running in one of its specified modes. If a rule has no modes specified, it is +always active. + +- `default`: The standard interactive mode where most write tools require + confirmation. +- `autoEdit`: Optimized for automated code editing; some write tools may be + auto-approved. +- `plan`: A strict, read-only mode for research and design. See [Customizing + Plan Mode Policies]. +- `yolo`: A mode where all tools are auto-approved (use with extreme caution). ## Rule matching @@ -303,3 +311,5 @@ out-of-the-box experience. - In **`yolo`** mode, a high-priority rule allows all tools. - In **`autoEdit`** mode, rules allow certain write operations to happen without prompting. + +[Customizing Plan Mode Policies]: /docs/cli/plan-mode.md#customizing-policies diff --git a/packages/core/src/policy/config.test.ts b/packages/core/src/policy/config.test.ts index 25f7e4a150..620cdd8500 100644 --- a/packages/core/src/policy/config.test.ts +++ b/packages/core/src/policy/config.test.ts @@ -951,4 +951,107 @@ name = "invalid-name" vi.doUnmock('node:fs/promises'); }); + + it('should allow overriding Plan Mode deny with user policy', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + const normalizedPath = nodePath.normalize(path.toString()); + if (normalizedPath.includes(nodePath.normalize('.gemini/policies'))) { + return [ + { + name: 'user-plan.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + const mockReadFile = vi.fn( + async ( + path: Parameters[0], + options: Parameters[1], + ) => { + const normalizedPath = nodePath.normalize(path.toString()); + if (normalizedPath.includes('user-plan.toml')) { + return ` +[[rule]] +toolName = "run_shell_command" +commandPrefix = ["git status", "git diff"] +decision = "allow" +priority = 100 +modes = ["plan"] + +[[rule]] +toolName = "codebase_investigator" +decision = "allow" +priority = 100 +modes = ["plan"] +`; + } + return actualFs.readFile(path, options); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./config.js'); + + const settings: PolicySettings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.PLAN, + nodePath.join(__dirname, 'policies'), + ); + + const shellRules = config.rules?.filter( + (r) => + r.toolName === 'run_shell_command' && + r.decision === PolicyDecision.ALLOW && + r.modes?.includes(ApprovalMode.PLAN) && + r.argsPattern, + ); + expect(shellRules).toHaveLength(2); + expect( + shellRules?.some((r) => r.argsPattern?.test('{"command":"git status"}')), + ).toBe(true); + expect( + shellRules?.some((r) => r.argsPattern?.test('{"command":"git diff"}')), + ).toBe(true); + expect( + shellRules?.every( + (r) => !r.argsPattern?.test('{"command":"git commit"}'), + ), + ).toBe(true); + + const subagentRule = config.rules?.find( + (r) => + r.toolName === 'codebase_investigator' && + r.decision === PolicyDecision.ALLOW && + r.modes?.includes(ApprovalMode.PLAN), + ); + expect(subagentRule).toBeDefined(); + expect(subagentRule?.priority).toBeCloseTo(2.1, 5); + + vi.doUnmock('node:fs/promises'); + }); });