feat(plan): support replace tool in plan mode to edit plans (#18379)

This commit is contained in:
Jerop Kipruto
2026-02-05 12:51:35 -05:00
committed by GitHub
parent e4c80e6382
commit 4a6e3eb646
5 changed files with 61 additions and 108 deletions
@@ -323,116 +323,64 @@ describe('Policy Engine Integration Tests', () => {
).toBe(PolicyDecision.DENY); ).toBe(PolicyDecision.DENY);
}); });
it('should allow write_file to plans directory in Plan mode', async () => { describe.each(['write_file', 'replace'])(
const settings: Settings = {}; '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( // Valid plan file paths
settings, const validPaths = [
ApprovalMode.PLAN, '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/my-plan.md',
); '/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/feature_auth.md',
const engine = new PolicyEngine(config); ];
// Valid plan file path (64-char hex hash, .md extension, safe filename) for (const file_path of validPaths) {
const validPlanPath = expect(
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/my-plan.md'; (
expect( await engine.check(
( { name: toolName, args: { file_path } },
await engine.check( undefined,
{ name: 'write_file', args: { file_path: validPlanPath } }, )
undefined, ).decision,
) ).toBe(PolicyDecision.ALLOW);
).decision, }
).toBe(PolicyDecision.ALLOW); });
// Valid plan with underscore in filename it(`should deny ${toolName} outside plans directory`, async () => {
const validPlanPath2 = const settings: Settings = {};
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/feature_auth.md'; const config = await createPolicyEngineConfig(
expect( settings,
( ApprovalMode.PLAN,
await engine.check( );
{ name: 'write_file', args: { file_path: validPlanPath2 } }, const engine = new PolicyEngine(config);
undefined,
)
).decision,
).toBe(PolicyDecision.ALLOW);
});
it('should deny write_file outside plans directory in Plan mode', async () => { const invalidPaths = [
const settings: Settings = {}; '/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( for (const file_path of invalidPaths) {
settings, expect(
ApprovalMode.PLAN, (
); await engine.check(
const engine = new PolicyEngine(config); { name: toolName, args: { file_path } },
undefined,
// Write to workspace (not plans dir) should be denied )
expect( ).decision,
( ).toBe(PolicyDecision.DENY);
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);
});
it('should verify priority ordering works correctly in practice', async () => { it('should verify priority ordering works correctly in practice', async () => {
const settings: Settings = { const settings: Settings = {
@@ -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: The following read-only tools are available in Plan Mode:
- \`write_file\` - Save plans to the plans directory (see Plan Storage below) - \`write_file\` - Save plans to the plans directory (see Plan Storage below)
- \`replace\` - Update plans in the plans directory
## Plan Storage ## Plan Storage
- Save your plans as Markdown (.md) files ONLY within: \`/tmp/project-temp/plans/\` - Save your plans as Markdown (.md) files ONLY within: \`/tmp/project-temp/plans/\`
+4 -1
View File
@@ -327,7 +327,10 @@ describe('createPolicyEngineConfig', () => {
ApprovalMode.AUTO_EDIT, ApprovalMode.AUTO_EDIT,
); );
const rule = config.rules?.find( 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(); expect(rule).toBeDefined();
// Priority 15 in default tier → 1.015 // Priority 15 in default tier → 1.015
+2 -2
View File
@@ -77,9 +77,9 @@ decision = "ask_user"
priority = 50 priority = 50
modes = ["plan"] modes = ["plan"]
# Allow write_file for .md files in plans directory # Allow write_file and replace for .md files in plans directory
[[rule]] [[rule]]
toolName = "write_file" toolName = ["write_file", "replace"]
decision = "allow" decision = "allow"
priority = 50 priority = 50
modes = ["plan"] modes = ["plan"]
+1
View File
@@ -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: The following read-only tools are available in Plan Mode:
${options.planModeToolsList} ${options.planModeToolsList}
- \`${WRITE_FILE_TOOL_NAME}\` - Save plans to the plans directory (see Plan Storage below) - \`${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 ## Plan Storage
- Save your plans as Markdown (.md) files ONLY within: \`${options.plansDir}/\` - Save your plans as Markdown (.md) files ONLY within: \`${options.plansDir}/\`