diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index 935248ab64..49b603a126 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -323,116 +323,64 @@ describe('Policy Engine Integration Tests', () => { ).toBe(PolicyDecision.DENY); }); - it('should allow write_file to plans directory in Plan mode', async () => { - const settings: Settings = {}; + describe.each(['write_file', 'replace'])( + 'Plan Mode policy for %s', + (toolName) => { + it(`should allow ${toolName} to plans directory`, async () => { + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.PLAN, + ); + const engine = new PolicyEngine(config); - const config = await createPolicyEngineConfig( - settings, - ApprovalMode.PLAN, - ); - const engine = new PolicyEngine(config); + // Valid plan file paths + const validPaths = [ + '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/my-plan.md', + '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/feature_auth.md', + ]; - // Valid plan file path (64-char hex hash, .md extension, safe filename) - const validPlanPath = - '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/my-plan.md'; - expect( - ( - await engine.check( - { name: 'write_file', args: { file_path: validPlanPath } }, - undefined, - ) - ).decision, - ).toBe(PolicyDecision.ALLOW); + for (const file_path of validPaths) { + expect( + ( + await engine.check( + { name: toolName, args: { file_path } }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.ALLOW); + } + }); - // Valid plan with underscore in filename - const validPlanPath2 = - '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/feature_auth.md'; - expect( - ( - await engine.check( - { name: 'write_file', args: { file_path: validPlanPath2 } }, - undefined, - ) - ).decision, - ).toBe(PolicyDecision.ALLOW); - }); + it(`should deny ${toolName} outside plans directory`, async () => { + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.PLAN, + ); + const engine = new PolicyEngine(config); - it('should deny write_file outside plans directory in Plan mode', async () => { - const settings: Settings = {}; + const invalidPaths = [ + '/project/src/file.ts', // Workspace + '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js', // Wrong extension + '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal + '/home/user/.gemini/tmp/abc123/plans/plan.md', // Invalid hash length + '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/subdir/plan.md', // Subdirectory + ]; - const config = await createPolicyEngineConfig( - settings, - ApprovalMode.PLAN, - ); - const engine = new PolicyEngine(config); - - // Write to workspace (not plans dir) should be denied - expect( - ( - await engine.check( - { name: 'write_file', args: { file_path: '/project/src/file.ts' } }, - undefined, - ) - ).decision, - ).toBe(PolicyDecision.DENY); - - // Write to plans dir but wrong extension should be denied - const wrongExtPath = - '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js'; - expect( - ( - await engine.check( - { name: 'write_file', args: { file_path: wrongExtPath } }, - undefined, - ) - ).decision, - ).toBe(PolicyDecision.DENY); - - // Path traversal attempt should be denied (filename contains /) - const traversalPath = - '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md'; - expect( - ( - await engine.check( - { name: 'write_file', args: { file_path: traversalPath } }, - undefined, - ) - ).decision, - ).toBe(PolicyDecision.DENY); - - // Invalid hash length should be denied - const shortHashPath = '/home/user/.gemini/tmp/abc123/plans/plan.md'; - expect( - ( - await engine.check( - { name: 'write_file', args: { file_path: shortHashPath } }, - undefined, - ) - ).decision, - ).toBe(PolicyDecision.DENY); - }); - - it('should deny write_file to subdirectories in Plan mode', async () => { - const settings: Settings = {}; - - const config = await createPolicyEngineConfig( - settings, - ApprovalMode.PLAN, - ); - const engine = new PolicyEngine(config); - - // Write to subdirectory should be denied - const subdirPath = - '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/subdir/plan.md'; - expect( - ( - await engine.check( - { name: 'write_file', args: { file_path: subdirPath } }, - undefined, - ) - ).decision, - ).toBe(PolicyDecision.DENY); - }); + for (const file_path of invalidPaths) { + expect( + ( + await engine.check( + { name: toolName, args: { file_path } }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.DENY); + } + }); + }, + ); it('should verify priority ordering works correctly in practice', async () => { const settings: Settings = { diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index 611ba2721e..be6ffd3493 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -130,6 +130,7 @@ You are operating in **Plan Mode** - a structured planning workflow for designin The following read-only tools are available in Plan Mode: - \`write_file\` - Save plans to the plans directory (see Plan Storage below) +- \`replace\` - Update plans in the plans directory ## Plan Storage - Save your plans as Markdown (.md) files ONLY within: \`/tmp/project-temp/plans/\` diff --git a/packages/core/src/policy/config.test.ts b/packages/core/src/policy/config.test.ts index 7b310027e0..cebe6a8d4b 100644 --- a/packages/core/src/policy/config.test.ts +++ b/packages/core/src/policy/config.test.ts @@ -327,7 +327,10 @@ describe('createPolicyEngineConfig', () => { ApprovalMode.AUTO_EDIT, ); const rule = config.rules?.find( - (r) => r.toolName === 'replace' && r.decision === PolicyDecision.ALLOW, + (r) => + r.toolName === 'replace' && + r.decision === PolicyDecision.ALLOW && + r.modes?.includes(ApprovalMode.AUTO_EDIT), ); expect(rule).toBeDefined(); // Priority 15 in default tier → 1.015 diff --git a/packages/core/src/policy/policies/plan.toml b/packages/core/src/policy/policies/plan.toml index 4bcecab29f..74f1777747 100644 --- a/packages/core/src/policy/policies/plan.toml +++ b/packages/core/src/policy/policies/plan.toml @@ -77,9 +77,9 @@ decision = "ask_user" priority = 50 modes = ["plan"] -# Allow write_file for .md files in plans directory +# Allow write_file and replace for .md files in plans directory [[rule]] -toolName = "write_file" +toolName = ["write_file", "replace"] decision = "allow" priority = 50 modes = ["plan"] diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index 2fc43a4b7a..2b18832380 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -305,6 +305,7 @@ You are operating in **Plan Mode** - a structured planning workflow for designin The following read-only tools are available in Plan Mode: ${options.planModeToolsList} - \`${WRITE_FILE_TOOL_NAME}\` - Save plans to the plans directory (see Plan Storage below) +- \`${EDIT_TOOL_NAME}\` - Update plans in the plans directory ## Plan Storage - Save your plans as Markdown (.md) files ONLY within: \`${options.plansDir}/\`