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

View File

@@ -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 = {

View File

@@ -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/\`

View File

@@ -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

View File

@@ -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"]

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:
${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}/\`