fix(core): support nested directory structure in Plan Mode write_file and edit

This commit is contained in:
Mahima Shanware
2026-05-19 20:28:50 +00:00
parent a7b3b865ce
commit 7b5245acee
11 changed files with 346 additions and 46 deletions
+32 -3
View File
@@ -449,7 +449,8 @@
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
"license": "(Apache-2.0 AND BSD-3-Clause)",
"peer": true
},
"node_modules/@bundled-es-modules/cookie": {
"version": "2.0.1",
@@ -1473,6 +1474,7 @@
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
"integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.7.13",
"@js-sdsl/ordered-map": "^4.4.2"
@@ -2150,6 +2152,7 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2330,6 +2333,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2379,6 +2383,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2753,6 +2758,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2786,6 +2792,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0"
@@ -2840,6 +2847,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0",
@@ -4046,6 +4054,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4319,6 +4328,7 @@
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.58.2",
"@typescript-eslint/types": "8.58.2",
@@ -5149,6 +5159,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7300,7 +7311,8 @@
"version": "0.0.1581282",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",
"integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -7885,6 +7897,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8413,6 +8426,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -9695,6 +9709,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -9976,6 +9991,7 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.9.tgz",
"integrity": "sha512-RL9sSiLQZECnjbmBwjIHOp8yVGdWF7C/uifg7ISv/e+F3nLNsfl7FdUFQs8iZARFMJAYxMFpxW6OW+HSt9drwQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-escapes": "^7.0.0",
"ansi-styles": "^6.2.3",
@@ -13823,6 +13839,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13833,6 +13850,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -16001,6 +16019,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16223,7 +16242,8 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -16231,6 +16251,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16410,6 +16431,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16477,6 +16499,7 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -16863,6 +16886,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -17433,6 +17457,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17445,6 +17470,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -18083,6 +18109,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -18517,6 +18544,7 @@
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.8.0",
"@js-sdsl/ordered-map": "^4.4.2"
@@ -18620,6 +18648,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -191,9 +191,9 @@ describe('UserSimulator', () => {
});
it('should terminate if terminal state does not change after 3 consecutive inputs', async () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
return undefined as never;
});
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
const simulator = new UserSimulator(
mockConfig,
mockGetScreen,
+26 -22
View File
@@ -274,8 +274,8 @@ Look carefully at the screen and determine the CLI's current state:
STATE 1: The agent is busy (e.g., streaming a response, executing a tool, or showing a progress message). It is actively working and NOT waiting for text input or user approval.
- In this case, your action MUST be exactly: <WAIT>
STATE 2: The agent is waiting for you to authorize a tool, confirm an action, or answer a specific multi-choice question (e.g., "Action Required", "Allow execution", numbered options, "[Y/n]").
- In this case, your action MUST be the exact raw characters to select the option and submit it (e.g., 1\\r, 2\\r, y\\r, n\\r, or just \\r if the default option is acceptable). Do NOT output <DONE> or "Thank you". You must unblock the agent and allow it to run the tool. This state takes precedence even if timers or background messages are visible.
STATE 2: The agent is waiting for you to authorize a tool, confirm an action, or answer a specific multi-choice/text question (which might be part of a multi-tabbed question dialog, including finalizing a list of answers on a "Review" tab or screen).
- In this case, your action MUST be the exact raw characters to select the option and submit it (e.g., 1\\r, 2\\r, y\\r, n\\r, or just \\r if you are on a Review tab/screen to submit all answers or if the default option is acceptable). Do NOT output <DONE> or "Thank you". You must unblock the agent and allow it to run the tool. This state takes precedence even if timers or background messages are visible.
STATE 3: The agent has finished its current thought process AND is idle, waiting for a NEW general text prompt (usually indicated by a "> Type your message" prompt).
- First, verify that the ACTUAL task is fully complete based on your original goal. Do not stop at intermediate steps like planning or syntax checking.
@@ -290,8 +290,11 @@ CRITICAL RULES:
- RULE 1: If there is a clear confirmation prompt (e.g. "[Y/n]", "1) Allow Once") or an input cursor (">"), YOU MUST RESPOND (State 2 or 3). Detect these states aggressively. Only <WAIT> (Rule 1 fallback) if the agent is truly mid-process with no interactive markers visible.
- RULE 2: If there is an "Action Required" or confirmation prompt on the screen, YOU MUST HANDLE IT (State 2). This takes precedence over everything else.
- RULE 3: If prompted to allow execution of a command with options like 'Allow once' and 'Allow for this session', you MUST choose the option for 'Allow for this session' (typically by sending '2\\r').
- RULE 4: Use the "session_notes" field to record important facts that are scrolling off the screen (e.g., test results, proposed plans, file names, errors). Keep notes extremely brief. DO NOT record transient states like "Agent is thinking". This memory helps you maintain context across the session.
- RULE 5: You MUST output a strictly formatted JSON object with no markdown wrappers or extra text.
- RULE 4: If the agent is presenting multiple questions in a tabbed dialog (often indicated by progress/tab headers like "Question 1", "Question 2", or "Review" at the top/bottom, or a "Review your answers:" section):
- If you are on an individual question tab, enter the option (e.g., 1\\r, 2\\r) or type your text response followed by \\r to answer it. Answering a question will auto-advance to the next tab.
- If you are on the "Review" tab (indicated by "Review your answers:" or a "Review" header), you MUST send \\r (Enter) to submit and finalize the answers.
- RULE 5: Use the "session_notes" field to record important facts that are scrolling off the screen (e.g., test results, proposed plans, file names, errors). Keep notes extremely brief. DO NOT record transient states like "Agent is thinking". This memory helps you maintain context across the session.
- RULE 6: You MUST output a strictly formatted JSON object with no markdown wrappers or extra text.
JSON FORMAT:
{
@@ -346,9 +349,7 @@ ${strippedScreen}
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
cleanJson = cleanJson.substring(startIdx, endIdx + 1);
} else {
cleanJson = cleanJson
.replace(/^\`\`\`json\s*|\s*\`\`\`$/gm, '')
.trim();
cleanJson = cleanJson.replace(/^```json\s*|\s*```$/gm, '').trim();
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
parsedJson = JSON.parse(cleanJson) as SimulatorResponse;
@@ -372,7 +373,7 @@ ${strippedScreen}
/^\d+\\r$/.test(text) ||
text === '\\r'
) {
responseText = text.replace(/^[\`\"']+|[\`\"']+$/g, '');
responseText = text.replace(/^[`"']+|[`"']+/g, '');
} else {
responseText = ''; // Prevent typing broken JSON string
}
@@ -436,22 +437,25 @@ ${strippedScreen}
);
}
if (false) /* Disabled dynamic knowledge generation for evaluation stability */ {
const newKnowledge = `- ${parsedJson.new_rule}\n`;
// eslint-disable-next-line no-constant-condition
if (false) {
/* Disabled dynamic knowledge generation for evaluation stability */ const newKnowledge = `- ${parsedJson.new_rule}\n`;
this.knowledgeBase += newKnowledge;
try {
fs.appendFileSync(this.editableKnowledgeFile, newKnowledge);
debugLogger.log(
`[SIMULATOR] Saved new knowledge to ${this.editableKnowledgeFile}`,
);
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Saved new knowledge to ${this.editableKnowledgeFile}\n\n`,
);
const file = this.editableKnowledgeFile;
if (file) {
try {
fs.appendFileSync(file!, newKnowledge);
debugLogger.log(`[SIMULATOR] Saved new knowledge to ${file}`);
const interactions = this.interactionsFile;
if (interactions) {
fs.appendFileSync(
interactions!,
`[LOG] [SIMULATOR] Saved new knowledge to ${file}\n\n`,
);
}
} catch (e) {
debugLogger.error(`Failed to append knowledge`, e);
}
} catch (e) {
debugLogger.error(`Failed to append knowledge`, e);
}
}
+80 -2
View File
@@ -1323,6 +1323,14 @@ function doIt() {
});
describe('plan mode', () => {
beforeEach(() => {
vi.mocked(mockConfig.isPlanMode).mockReturnValue(true);
});
afterEach(() => {
vi.mocked(mockConfig.isPlanMode).mockReturnValue(false);
});
it('should allow edits to plans directory when isPlanMode is true', async () => {
const mockProjectTempDir = path.join(tempDir, 'project');
fs.mkdirSync(mockProjectTempDir);
@@ -1332,8 +1340,6 @@ function doIt() {
const plansDir = path.join(mockProjectTempDir, 'plans');
fs.mkdirSync(plansDir);
vi.mocked(mockConfig.isPlanMode).mockReturnValue(true);
vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue(plansDir);
const filePath = path.join(rootDir, 'test-file.txt');
@@ -1360,5 +1366,77 @@ function doIt() {
fs.rmSync(plansDir, { recursive: true, force: true });
});
it('should preserve nested directory structure within the plans directory in Plan Mode', async () => {
const mockProjectTempDir = path.join(tempDir, 'project');
fs.mkdirSync(mockProjectTempDir);
vi.mocked(mockConfig.storage.getProjectTempDir).mockReturnValue(
mockProjectTempDir,
);
const plansDir = path.join(mockProjectTempDir, 'plans');
fs.mkdirSync(plansDir);
vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue(plansDir);
const nestedDir = path.join(plansDir, 'tracks', 'fibsqrt_20260519');
fs.mkdirSync(nestedDir, { recursive: true });
const planFilePath = path.join(nestedDir, 'spec.md');
const initialContent = 'some initial content';
fs.writeFileSync(planFilePath, initialContent, 'utf8');
const params: EditToolParams = {
file_path: 'tracks/fibsqrt_20260519/spec.md',
instruction: 'Replace initial with new',
old_string: 'initial',
new_string: 'new',
};
const invocation = tool.build(params);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
expect(result.llmContent).toMatch(/Successfully modified file/);
expect(fs.readFileSync(planFilePath, 'utf8')).toBe('some new content');
fs.rmSync(plansDir, { recursive: true, force: true });
});
it('should strip the leading plansDir folder name segment if present in path', async () => {
const mockProjectTempDir = path.join(tempDir, 'project');
fs.mkdirSync(mockProjectTempDir);
vi.mocked(mockConfig.storage.getProjectTempDir).mockReturnValue(
mockProjectTempDir,
);
const plansDir = path.join(mockProjectTempDir, 'plans');
fs.mkdirSync(plansDir);
vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue(plansDir);
const nestedDir = path.join(plansDir, 'tracks', 'fibsqrt_20260519');
fs.mkdirSync(nestedDir, { recursive: true });
const planFilePath = path.join(nestedDir, 'spec.md');
const initialContent = 'some initial content';
fs.writeFileSync(planFilePath, initialContent, 'utf8');
const params: EditToolParams = {
file_path: 'plans/tracks/fibsqrt_20260519/spec.md',
instruction: 'Replace initial with new',
old_string: 'initial',
new_string: 'new',
};
const invocation = tool.build(params);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
expect(result.llmContent).toMatch(/Successfully modified file/);
expect(fs.readFileSync(planFilePath, 'utf8')).toBe('some new content');
fs.rmSync(plansDir, { recursive: true, force: true });
});
});
});
+11 -4
View File
@@ -27,6 +27,7 @@ import { buildFilePathArgsPattern } from '../policy/utils.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { ToolErrorType } from './tool-error.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { resolvePlanPath } from '../utils/planUtils.js';
import { isNodeError } from '../utils/errors.js';
import { correctPath } from '../utils/pathCorrector.js';
import type { Config } from '../config/config.js';
@@ -465,10 +466,10 @@ class EditToolInvocation
() => this.config.getApprovalMode(),
);
if (this.config.isPlanMode()) {
const safeFilename = path.basename(this.params.file_path);
this.resolvedPath = path.join(
this.resolvedPath = resolvePlanPath(
this.params.file_path,
this.config.storage.getPlansDir(),
safeFilename,
this.config.getTargetDir(),
);
} else if (!path.isAbsolute(this.params.file_path)) {
const result = correctPath(this.params.file_path, this.config);
@@ -1054,7 +1055,13 @@ export class EditTool
}
let resolvedPath: string;
if (!path.isAbsolute(params.file_path)) {
if (this.config.isPlanMode()) {
resolvedPath = resolvePlanPath(
params.file_path,
this.config.storage.getPlansDir(),
this.config.getTargetDir(),
);
} else if (!path.isAbsolute(params.file_path)) {
const result = correctPath(params.file_path, this.config);
if (result.success) {
resolvedPath = result.correctedPath;
@@ -508,5 +508,28 @@ Ask the user for specific feedback on how to improve the plan.`,
});
expect(result).toBeNull();
});
it('should accept nested valid path within plans directory', () => {
const nestedDir = path.join(mockPlansDir, 'tracks', 'fibsqrt_20260519');
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(path.join(nestedDir, 'spec.md'), '# Content');
const result = tool.validateToolParams({
plan_filename: 'tracks/fibsqrt_20260519/spec.md',
});
expect(result).toBeNull();
});
it('should strip the leading plansDir folder name segment if present in path', () => {
const plansDirName = path.basename(mockPlansDir);
const nestedDir = path.join(mockPlansDir, 'tracks', 'fibsqrt_20260519');
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(path.join(nestedDir, 'spec.md'), '# Content');
const result = tool.validateToolParams({
plan_filename: `${plansDirName}/tracks/fibsqrt_20260519/spec.md`,
});
expect(result).toBeNull();
});
});
});
+14 -6
View File
@@ -19,7 +19,11 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
import path from 'node:path';
import type { Config } from '../config/config.js';
import { EXIT_PLAN_MODE_TOOL_NAME } from './tool-names.js';
import { validatePlanPath, validatePlanContent } from '../utils/planUtils.js';
import {
validatePlanPath,
validatePlanContent,
resolvePlanPath,
} from '../utils/planUtils.js';
import { ApprovalMode } from '../policy/types.js';
import { resolveToRealPath, isSubpath } from '../utils/paths.js';
import { logPlanExecution } from '../telemetry/loggers.js';
@@ -60,11 +64,11 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
return 'plan_filename is required.';
}
const safeFilename = path.basename(params.plan_filename);
const plansDir = resolveToRealPath(this.config.storage.getPlansDir());
const resolvedPath = path.join(
const resolvedPath = resolvePlanPath(
params.plan_filename,
this.config.storage.getPlansDir(),
safeFilename,
this.config.getTargetDir(),
);
const realPath = resolveToRealPath(resolvedPath);
@@ -122,6 +126,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
const pathError = await validatePlanPath(
this.params.plan_filename,
this.config.storage.getPlansDir(),
this.config.getTargetDir(),
);
if (pathError) {
this.planValidationError = pathError;
@@ -179,8 +184,11 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
* Note: Validation is done in validateToolParamValues, so this assumes the path is valid.
*/
private getResolvedPlanPath(): string {
const safeFilename = path.basename(this.params.plan_filename);
return path.join(this.config.storage.getPlansDir(), safeFilename);
return resolvePlanPath(
this.params.plan_filename,
this.config.storage.getPlansDir(),
this.config.getTargetDir(),
);
}
async execute({ abortSignal: _signal }: ExecuteOptions): Promise<ToolResult> {
@@ -109,6 +109,7 @@ const mockConfigInternal = {
getActiveModel: () => 'test-model',
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
},
};
@@ -147,6 +148,7 @@ describe('WriteFileTool', () => {
const workspaceContext = new WorkspaceContext(rootDir, [plansDir]);
const mockStorage = {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
getPlansDir: vi.fn().mockReturnValue(plansDir),
};
mockConfig = {
@@ -1113,4 +1115,38 @@ describe('WriteFileTool', () => {
);
});
});
describe('Plan Mode path resolution', () => {
beforeEach(() => {
vi.mocked(mockConfigInternal.isPlanMode).mockReturnValue(true);
vi.mocked(mockConfigInternal.storage.getPlansDir).mockReturnValue(
plansDir,
);
});
afterEach(() => {
vi.mocked(mockConfigInternal.isPlanMode).mockReturnValue(false);
});
it('should preserve nested directory structure within the plans directory', () => {
const planFilePath = 'tracks/fibsqrt_20260519/spec.md';
const params = { file_path: planFilePath, content: '# Spec' };
const invocation = tool.build(params);
expect(
(invocation as unknown as { resolvedPath: string }).resolvedPath,
).toBe(path.resolve(plansDir, 'tracks/fibsqrt_20260519/spec.md'));
});
it('should strip the leading plansDir folder name segment if present in path', () => {
const plansDirName = path.basename(plansDir);
const planFilePath = `${plansDirName}/tracks/fibsqrt_20260519/spec.md`;
const params = { file_path: planFilePath, content: '# Spec' };
const invocation = tool.build(params);
expect(
(invocation as unknown as { resolvedPath: string }).resolvedPath,
).toBe(path.resolve(plansDir, 'tracks/fibsqrt_20260519/spec.md'));
});
});
});
+4 -3
View File
@@ -29,6 +29,7 @@ import {
import { buildFilePathArgsPattern } from '../policy/utils.js';
import { ToolErrorType } from './tool-error.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { resolvePlanPath } from '../utils/planUtils.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js';
import { ensureCorrectFileContent } from '../utils/editCorrector.js';
import { detectLineEnding } from '../utils/textUtils.js';
@@ -168,10 +169,10 @@ class WriteFileToolInvocation extends BaseToolInvocation<
);
if (this.config.isPlanMode()) {
const safeFilename = path.basename(this.params.file_path);
this.resolvedPath = path.join(
this.resolvedPath = resolvePlanPath(
this.params.file_path,
this.config.storage.getPlansDir(),
safeFilename,
this.config.getTargetDir(),
);
} else {
this.resolvedPath = path.resolve(
+54 -1
View File
@@ -8,7 +8,11 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import path from 'node:path';
import * as fs from 'node:fs';
import os from 'node:os';
import { validatePlanPath, validatePlanContent } from './planUtils.js';
import {
validatePlanPath,
validatePlanContent,
resolvePlanPath,
} from './planUtils.js';
describe('planUtils', () => {
let tempRootDir: string;
@@ -57,6 +61,55 @@ describe('planUtils', () => {
const result = await validatePlanPath(maliciousPath, plansDir);
expect(result).toContain('Access denied');
});
it('should validate a nested path within the plans directory', async () => {
const nestedDir = path.join(plansDir, 'tracks', 'fibsqrt_20260519');
fs.mkdirSync(nestedDir, { recursive: true });
const planPath = path.join('tracks', 'fibsqrt_20260519', 'spec.md');
const fullPath = path.join(plansDir, planPath);
fs.writeFileSync(fullPath, '# Nested Spec');
const result = await validatePlanPath(planPath, plansDir, tempRootDir);
expect(result).toBeNull();
});
});
describe('resolvePlanPath', () => {
it('should resolve simple filenames relative to plansDir', () => {
const result = resolvePlanPath(
'implementation_plan.md',
plansDir,
tempRootDir,
);
expect(result).toBe(path.join(plansDir, 'implementation_plan.md'));
});
it('should preserve subdirectories if already inside plansDir', () => {
const planPath = path.join(
'plans',
'tracks',
'fibsqrt_20260519',
'spec.md',
);
const result = resolvePlanPath(planPath, plansDir, tempRootDir);
expect(result).toBe(
path.join(plansDir, 'tracks', 'fibsqrt_20260519', 'spec.md'),
);
});
it('should resolve paths relative to plansDir if they contain subdirectories', () => {
const planPath = path.join('tracks', 'fibsqrt_20260519', 'spec.md');
const result = resolvePlanPath(planPath, plansDir, tempRootDir);
expect(result).toBe(
path.join(plansDir, 'tracks', 'fibsqrt_20260519', 'spec.md'),
);
});
it('should fallback to safe basename when escaping', () => {
const planPath = '../../escaped.md';
const result = resolvePlanPath(planPath, plansDir, tempRootDir);
expect(result).toBe(path.join(plansDir, 'escaped.md'));
});
});
describe('validatePlanContent', () => {
+63 -2
View File
@@ -22,6 +22,67 @@ export const PlanErrorMessages = {
READ_FAILURE: (detail: string) => `Failed to read plan file: ${detail}`,
} as const;
/**
* Safely resolves a plan path within the plans directory, preserving subdirectories
* if they are within the plans directory context.
*
* @param planPath The input file path for the plan.
* @param plansDir The authorized project plans directory.
* @param targetDir The project root directory.
* @returns The resolved safe absolute path.
*/
export function resolvePlanPath(
planPath: string,
plansDir: string,
targetDir: string = process.cwd(),
): string {
const realPlansDir = resolveToRealPath(plansDir);
const plansDirName = path.basename(plansDir);
let normalizedPlanPath = planPath;
if (!path.isAbsolute(planPath)) {
const segments = planPath.split(/[\\/]+/);
if (segments.length > 1 && segments[0] === plansDirName) {
normalizedPlanPath = segments.slice(1).join(path.sep);
}
}
// 1. Try resolving relative to project root (targetDir)
const resolved = path.isAbsolute(normalizedPlanPath)
? normalizedPlanPath
: path.resolve(targetDir, normalizedPlanPath);
try {
const realResolved = resolveToRealPath(resolved);
if (isSubpath(realPlansDir, realResolved)) {
return resolved;
}
} catch {
const directResolved = path.resolve(resolved);
if (isSubpath(realPlansDir, directResolved)) {
return resolved;
}
}
// 2. Try resolving relative to plansDir
const nestedResolved = path.resolve(plansDir, normalizedPlanPath);
try {
const realNested = resolveToRealPath(nestedResolved);
if (isSubpath(realPlansDir, realNested)) {
return nestedResolved;
}
} catch {
const directNested = path.resolve(nestedResolved);
if (isSubpath(realPlansDir, directNested)) {
return nestedResolved;
}
}
// 3. Fallback to standard safe behavior (basename) to avoid traversal
const safeFilename = path.basename(planPath);
return path.join(plansDir, safeFilename);
}
/**
* Validates a plan file path for safety (traversal) and existence.
* @param planPath The untrusted path to the plan file.
@@ -32,9 +93,9 @@ export const PlanErrorMessages = {
export async function validatePlanPath(
planPath: string,
plansDir: string,
targetDir?: string,
): Promise<string | null> {
const safeFilename = path.basename(planPath);
const resolvedPath = path.join(plansDir, safeFilename);
const resolvedPath = resolvePlanPath(planPath, plansDir, targetDir);
const realPath = resolveToRealPath(resolvedPath);
const realPlansDir = resolveToRealPath(plansDir);