Compare commits

...

19 Commits

Author SHA1 Message Date
Moisés Gana Obregón a2dda25890 fix(core,cli): properly resolve nested paths and plan dirs in exit_plan_mode
- **exit_plan_mode.ts**: Fixed an issue where `path.basename` incorrectly stripped nested directories (e.g., `tracks/123/index.md` to `index.md`) by adopting `resolveAndValidatePlanPath`.
- **UI Components**: Updated `ExitPlanModeDialog.tsx` and `planCommand.ts` to use `config.getPlansDir()` instead of `config.storage.getPlansDir()`. This ensures the UI respects active extension plan directories (like Conductor) rather than falling back to the default temporary directory.
- **write-file.ts**: Updated to use `config.getPlansDir()` for consistency.
2026-04-17 17:31:32 +00:00
Moisés Gana Obregón 9b5c3775e0 fix(core): strip redundant plan directory prefix in plan mode
This fix updates `resolveAndValidatePlanPath` to detect and strip redundant prefixes from file paths when the agent provides a path that begins with the basename of the plans directory (e.g., stripping `conductor/` when writing to `conductor/product.md` inside the `conductor` plan directory).

 This prevents the creation of nested directories (e.g., `conductor/conductor/product.md`) while maintaining path compatibility between Plan Mode and Execution Mode.
2026-04-16 21:01:51 +00:00
Mahima Shanware 736690fd35 fix(core): ensure edit and write-file tools use storage.getPlansDir
This fixes test failures introduced during conflict resolution by strictly calling storage.getPlansDir as per the new config architecture.
2026-04-10 18:38:24 +00:00
Mahima Shanware c57123cd81 fix(core): refactor exit-plan-mode to use resolveAndValidatePlanPath consistently
This fixes unit test failures by centralizing path resolution logic.
2026-04-10 18:38:24 +00:00
Mahima Shanware 8d584e4d96 fix(core): handle nested plan files by resolving paths correctly
This fixes a bug where path.basename was incorrectly stripping directory structures from plan files (e.g., trying to write to plans/nested/file.md would incorrectly write to plans/file.md). By using path.resolve and verifying with isSubpath, nested files are now handled securely and correctly.
2026-04-10 18:33:02 +00:00
Mahima Shanware a99cd0be28 Merge branch 'main' into worktree-con-plan-bug
Resolves conflict in packages/core/src/tools/enter-plan-mode.test.ts by removing an assertion for directory creation, which has been centralized in config.ts in this branch.
2026-04-10 18:00:53 +00:00
Mahima Shanware 2ca07e8917 fix(core): prevent infinite loop in project registry short ID generation
Adds a hard limit to the while(true) loop in claimNewSlug to prevent tests from hanging indefinitely when fs.existsSync is mocked improperly. Refines error typing in the rename retry block to strictly use isNodeError.
2026-04-10 00:12:24 +00:00
Mahima Shanware 28c5fe7b28 fix(core): mitigate Windows EPERM flakiness in project registry saves
Introduces a retry mechanism for fs.promises.rename when saving the project registry. This resolves a known concurrency issue on Windows CI runners where multiple processes spinning up simultaneously during E2E tests cause file lock contention.
2026-04-09 20:16:58 +00:00
Mahima Shanware c29ab1afb8 fix(core): resolve getErrorMessage visibility and sandbox setting parsing
Moves the getErrorMessage import to the top of config.ts to avoid circularity-induced TS2304 in some environments. Also fixes a TypeError in sandbox_command.js when modern object-style sandbox settings are used in settings.json.
2026-04-09 19:16:56 +00:00
Mahima Shanware 74ce3eef0c fix(evals): refine plan mode eval prompt to ensure toolchain completion
Align the 'foo' test prompt with existing project conventions while ensuring the model has the 'informal agreement' signal required to proceed to formal approval and implementation.
2026-04-09 18:13:35 +00:00
Mahima Shanware 08ae3ff9b8 fix(core): align logging and error handling with strict development rules
Addressed reviewer feedback by replacing process.stderr.write with debugLogger.error in config.ts and using isNodeError in storage.ts to adhere to codebase standards.
2026-04-08 23:12:44 +00:00
Mahima Shanware fd8cfad6f9 fix(core): ensure JIT provisioning of plan directory only occurs in active plan mode
Address review bot feedback to prevent EACCES errors during startup by restricting filesystem mutation and workspace context registration to when the user is actively in an interactive plan session. Non-plan tool registration now uses the safely resolved path without attempting to create the directory.
2026-04-08 16:36:40 +00:00
Mahima Shanware 073ecc1d4c fix(core): handle plan dir EEXIST safely and rely on mkdir idempotency
This addresses a potential TOCTOU vulnerability and edge case identified during review. The redundant `fs.existsSync` check in `getPlansDir` has been removed, allowing `fs.mkdirSync(..., { recursive: true })` to safely handle directory idempotency.

By relying directly on `mkdirSync`, we ensure that if a non-directory file already exists at the target path, the system will correctly throw an `EEXIST` error rather than silently treating the file as a directory and crashing later during workspace registration.
2026-04-08 16:36:39 +00:00
Mahima Shanware b9d987b08d perf(core): cache initialized plan directories
Adds caching to getPlansDir to avoid redundant synchronous disk I/O and repeated workspace context registrations.
2026-04-08 16:36:39 +00:00
Mahima Shanware 8abe822ba1 fix(cli): consistently clear sticky extension context
This fixes a bug where the active extension context would remain sticky when a user switched from an extension command to a standard non-plan command, or to an extension without a plan directory.

The context is now correctly reset to undefined when an extension command without a plan directory is executed, preventing subsequent plan mode invocations from incorrectly targeting the previous extension's folder.
2026-04-08 16:36:39 +00:00
Mahima Shanware 0c63c05c45 fix(core): address extension context stickiness and symlink path resolution
This commit addresses two bugs identified during review:

1. Cleared the sticky `activeExtensionContext` when the standard `/plan` command is executed, ensuring subsequent prompts correctly target the default global plan directory.
2. Fixed a path resolution regression in `Storage.getPlansDir()` by constructing the fallback ENOENT path directly against the real project root. This prevents `isSubpath` validation failures and potential traversal vulnerabilities when the project root is a symlink.
2026-04-08 16:36:39 +00:00
Mahima Shanware 18f999a30d feat(cli): wire active extension context into slash command routing
Extracts the extension context from slash commands based on their registered metadata and sets it as the active context in the Config before execution. This enables the backend to dynamically route plan directories based on the extension that owns the invoked command.
2026-04-08 16:36:39 +00:00
Mahima Shanware 15aa924932 fix(core): migrate consumers to lazily-evaluated getPlansDir
Updates prompts and tool implementations (edit, write-file, enter/exit plan mode) to route through Config.getPlansDir() instead of Storage.getPlansDir(). This ensures the plan directory is lazily created exactly when these features attempt to use it, preventing ENOENT failures.
2026-04-08 16:34:05 +00:00
Mahima Shanware 2c4d03a843 feat(core): dynamic MRU plan directory resolution and lazy initialization
Introduces active extension context tracking in config to support dynamic switching of plan directories. Resolves circular dependency in storage by deferring plan directory creation until on-demand use, preventing ENOENT errors on non-existent paths.
2026-04-08 16:32:47 +00:00
32 changed files with 682 additions and 159 deletions
+39 -2
View File
@@ -297,13 +297,13 @@ describe('plan_mode', () => {
},
});
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: 'should transition from plan mode to normal execution and create a plan file from scratch',
params: {
settings,
},
prompt:
'Enter plan mode and plan to create a new module called foo. The plan should be saved as foo-plan.md. Then, exit plan mode.',
'Enter plan mode and plan to create a new module called foo. The plan should be saved as foo-plan.md. I agree with the strategy, so please create a detailed implementation plan and then execute it.',
assert: async (rig, result) => {
const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode');
expect(
@@ -372,4 +372,41 @@ describe('plan_mode', () => {
assertModelHasOutput(result);
},
});
evalTest('ALWAYS_PASSES', {
name: 'should handle nested plan directories correctly',
approvalMode: ApprovalMode.PLAN,
params: {
settings,
},
prompt:
'Please create a new architectural plan in a nested folder called "architecture/frontend-v2.md" within the plans directory. The plan should contain the text "# Frontend V2 Plan". Do not ask for user approval, just create the plan.',
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
const toolLogs = rig.readToolLogs();
const writeCalls = toolLogs.filter((log) =>
['write_file', 'replace'].includes(log.toolRequest.name),
);
const wroteToNestedPath = writeCalls.some((log) => {
try {
const args = JSON.parse(log.toolRequest.args);
return (
args.file_path &&
args.file_path.includes('architecture/frontend-v2.md')
);
} catch {
return false;
}
});
expect(
wroteToNestedPath,
'Expected model to successfully target the nested plan file path',
).toBe(true);
assertModelHasOutput(result);
},
});
});
+2 -1
View File
@@ -3796,7 +3796,8 @@ describe('loadCliConfig mcpEnabled', () => {
]);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.storage.getPlansDir()).toContain('ext-plans-dir');
config.setActiveExtensionContext('ext-plan');
expect(config.getPlansDir()).toContain('ext-plans-dir');
});
it('should NOT use plan directory from active extension when user has specified one', async () => {
+8 -6
View File
@@ -610,9 +610,12 @@ export async function loadCliConfig(
});
await extensionManager.loadExtensions();
const extensionPlanSettings = extensionManager
.getExtensions()
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
const extensionPlanDirs: Record<string, string> = {};
for (const ext of extensionManager.getExtensions()) {
if (ext.isActive && ext.plan?.directory) {
extensionPlanDirs[ext.name] = ext.plan.directory;
}
}
const experimentalJitContext = settings.experimental.jitContext;
@@ -982,9 +985,8 @@ export async function loadCliConfig(
plan: settings.general?.plan?.enabled ?? true,
tracker: settings.experimental?.taskTracker,
directWebFetch: settings.experimental?.directWebFetch,
planSettings: settings.general?.plan?.directory
? settings.general.plan
: (extensionPlanSettings ?? settings.general?.plan),
planSettings: settings.general?.plan,
extensionPlanDirs,
enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
@@ -23,6 +23,8 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
isInteractive: vi.fn(() => false),
isInitialized: vi.fn(() => true),
setTerminalBackground: vi.fn(),
setActiveExtensionContext: vi.fn(),
hasExtensionPlanDir: vi.fn(() => true),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
initialize: vi.fn().mockResolvedValue(undefined),
+151
View File
@@ -3195,6 +3195,157 @@ describe('AppContainer State Management', () => {
);
unmount();
});
it('sets activeExtensionContext when an extension command WITH a plan dir is executed', async () => {
const { checkPermissions } = await import(
'./hooks/atCommandProcessor.js'
);
vi.mocked(checkPermissions).mockResolvedValue([]);
mockedUseSlashCommandProcessor.mockReturnValue({
handleSlashCommand: vi.fn(),
slashCommands: [
{
name: 'conductor:setup',
extensionName: 'conductor',
description: 'test',
action: vi.fn(),
},
],
pendingHistoryItems: [],
commandContext: {},
shellConfirmationRequest: null,
confirmationRequest: null,
});
const spyHasExtensionPlanDir = vi
.spyOn(mockConfig, 'hasExtensionPlanDir')
.mockReturnValue(true);
const spySetActiveExtensionContext = vi.spyOn(
mockConfig,
'setActiveExtensionContext',
);
const { unmount } = await act(async () => renderAppContainer());
expect(capturedUIActions).toBeTruthy();
await act(async () =>
capturedUIActions.handleFinalSubmit('/conductor:setup'),
);
expect(spyHasExtensionPlanDir).toHaveBeenCalledWith('conductor');
expect(spySetActiveExtensionContext).toHaveBeenCalledWith('conductor');
unmount();
});
it('clears activeExtensionContext when an extension command WITHOUT a plan dir is executed', async () => {
const { checkPermissions } = await import(
'./hooks/atCommandProcessor.js'
);
vi.mocked(checkPermissions).mockResolvedValue([]);
mockedUseSlashCommandProcessor.mockReturnValue({
handleSlashCommand: vi.fn(),
slashCommands: [
{
name: 'other:cmd',
extensionName: 'other',
description: 'test',
action: vi.fn(),
},
],
pendingHistoryItems: [],
commandContext: {},
shellConfirmationRequest: null,
confirmationRequest: null,
});
const spyHasExtensionPlanDir = vi
.spyOn(mockConfig, 'hasExtensionPlanDir')
.mockReturnValue(false);
const spySetActiveExtensionContext = vi.spyOn(
mockConfig,
'setActiveExtensionContext',
);
const { unmount } = await act(async () => renderAppContainer());
expect(capturedUIActions).toBeTruthy();
await act(async () => capturedUIActions.handleFinalSubmit('/other:cmd'));
expect(spyHasExtensionPlanDir).toHaveBeenCalledWith('other');
expect(spySetActiveExtensionContext).toHaveBeenCalledWith(undefined);
unmount();
});
it('clears activeExtensionContext when /plan is explicitly executed', async () => {
const { checkPermissions } = await import(
'./hooks/atCommandProcessor.js'
);
vi.mocked(checkPermissions).mockResolvedValue([]);
mockedUseSlashCommandProcessor.mockReturnValue({
handleSlashCommand: vi.fn(),
slashCommands: [{ name: 'plan', description: 'test', action: vi.fn() }],
pendingHistoryItems: [],
commandContext: {},
shellConfirmationRequest: null,
confirmationRequest: null,
});
const spySetActiveExtensionContext = vi.spyOn(
mockConfig,
'setActiveExtensionContext',
);
const { unmount } = await act(async () => renderAppContainer());
expect(capturedUIActions).toBeTruthy();
await act(async () =>
capturedUIActions.handleFinalSubmit('/plan my task'),
);
expect(spySetActiveExtensionContext).toHaveBeenCalledWith(undefined);
unmount();
});
it('does NOT clear activeExtensionContext when a standard non-plan command is executed', async () => {
const { checkPermissions } = await import(
'./hooks/atCommandProcessor.js'
);
vi.mocked(checkPermissions).mockResolvedValue([]);
mockedUseSlashCommandProcessor.mockReturnValue({
handleSlashCommand: vi.fn(),
slashCommands: [{ name: 'help', description: 'test', action: vi.fn() }],
pendingHistoryItems: [],
commandContext: {},
shellConfirmationRequest: null,
confirmationRequest: null,
});
const spySetActiveExtensionContext = vi.spyOn(
mockConfig,
'setActiveExtensionContext',
);
const { unmount } = await act(async () => renderAppContainer());
expect(capturedUIActions).toBeTruthy();
await act(async () => capturedUIActions.handleFinalSubmit('/help'));
// It should not touch the context at all
expect(spySetActiveExtensionContext).not.toHaveBeenCalled();
unmount();
});
});
describe('Overflow Hint Handling', () => {
+18 -4
View File
@@ -1377,6 +1377,23 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}
const parsedCommand = parseSlashCommand(
submittedValue,
slashCommands ?? [],
);
if (config) {
if (parsedCommand.extensionContext) {
if (config.hasExtensionPlanDir(parsedCommand.extensionContext)) {
config.setActiveExtensionContext(parsedCommand.extensionContext);
} else {
config.setActiveExtensionContext(undefined);
}
} else if (parsedCommand.commandToExecute?.name === 'plan') {
config.setActiveExtensionContext(undefined);
}
}
const isSlash = isSlashCommand(submittedValue.trim());
const isIdle = streamingState === StreamingState.Idle;
const isAgentRunning =
@@ -1384,10 +1401,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isToolExecuting(pendingHistoryItems);
if (isSlash && isAgentRunning) {
const { commandToExecute } = parseSlashCommand(
submittedValue,
slashCommands ?? [],
);
const commandToExecute = parsedCommand.commandToExecute;
if (commandToExecute?.isSafeConcurrent) {
void handleSlashCommand(submittedValue);
addInput(submittedValue);
@@ -45,6 +45,7 @@ describe('clearCommand', () => {
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
}),
setActiveExtensionContext: vi.fn(),
injectionService: {
clear: mockHintClear,
},
+2 -1
View File
@@ -30,8 +30,9 @@ export const clearCommand: SlashCommand = {
await hookSystem.fireSessionEndEvent(SessionEndReason.Clear);
}
// Reset user steering hints
// Reset user steering hints and extension context
config?.injectionService.clear();
config?.setActiveExtensionContext(undefined);
// Start a new conversation recording with a new session ID
// We MUST do this before calling resetChat() so the new ChatRecordingService
+1 -1
View File
@@ -82,7 +82,7 @@ export const planCommand: SlashCommand = {
try {
const content = await processSingleFileContent(
approvedPlanPath,
config.storage.getPlansDir(),
config.getPlansDir(),
config.getFileSystemService(),
);
const fileName = path.basename(approvedPlanPath);
@@ -84,7 +84,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
try {
const pathError = await validatePlanPath(
planPath,
config.storage.getPlansDir(),
config.getPlansDir(),
);
if (ignore) return;
if (pathError) {
@@ -101,7 +101,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
const result = await processSingleFileContent(
planPath,
config.storage.getPlansDir(),
config.getPlansDir(),
config.getFileSystemService(),
);
+5 -1
View File
@@ -10,6 +10,7 @@ export type ParsedSlashCommand = {
commandToExecute: SlashCommand | undefined;
args: string;
canonicalPath: string[];
extensionContext?: string;
};
/**
@@ -69,6 +70,8 @@ export const parseSlashCommand = (
const args = parts.slice(pathIndex).join(' ');
const extensionContext = commandToExecute?.extensionName;
// Backtrack if the matched (sub)command doesn't take arguments but some were provided,
// AND the parent command is capable of handling them.
if (
@@ -82,8 +85,9 @@ export const parseSlashCommand = (
commandToExecute: parentCommand,
args: parts.slice(pathIndex - 1).join(' '),
canonicalPath: canonicalPath.slice(0, -1),
extensionContext: parentCommand.extensionName,
};
}
return { commandToExecute, args, canonicalPath };
return { commandToExecute, args, canonicalPath, extensionContext };
};
+107 -16
View File
@@ -3357,11 +3357,12 @@ describe('Plans Directory Initialization', () => {
afterEach(() => {
vi.mocked(fs.promises.mkdir).mockRestore();
vi.mocked(fs.promises.access).mockRestore?.();
vi.mocked(fs.mkdirSync).mockRestore?.();
vi.mocked(fs.existsSync).mockReturnValue(true); // Reset to default mock behavior
});
it('should add plans directory to workspace context if it exists', async () => {
vi.spyOn(fs.promises, 'access').mockResolvedValue(undefined);
it('should not eagerly create plans directory during initialization', async () => {
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
const config = new Config({
...baseParams,
plan: true,
@@ -3369,34 +3370,123 @@ describe('Plans Directory Initialization', () => {
await config.initialize();
const plansDir = config.storage.getPlansDir();
// Should NOT create the directory eagerly
expect(fs.promises.mkdir).not.toHaveBeenCalled();
// Should check if it exists
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);
expect(fs.mkdirSync).not.toHaveBeenCalled();
// Using storage directly to avoid triggering creation
const plansDir = config.storage.getPlansDir();
const context = config.getWorkspaceContext();
expect(context.getDirectories()).not.toContain(plansDir);
});
it('should create plans directory and add it to workspace context when getPlansDir is called and ApprovalMode.PLAN is active', async () => {
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
const config = new Config({
...baseParams,
plan: true,
});
await config.initialize();
config.setApprovalMode(ApprovalMode.PLAN);
const plansDir = config.getPlansDir();
expect(fs.mkdirSync).toHaveBeenCalledWith(plansDir, {
recursive: true,
});
const context = config.getWorkspaceContext();
expect(context.getDirectories()).toContain(plansDir);
});
it('should NOT add plans directory to workspace context if it does not exist', async () => {
vi.spyOn(fs.promises, 'access').mockRejectedValue({ code: 'ENOENT' });
it('should NOT create plans directory if ApprovalMode is not PLAN even if plan is enabled', async () => {
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
const config = new Config({
...baseParams,
plan: true,
});
await config.initialize();
// Default mode is DEFAULT, not PLAN
const plansDir = config.getPlansDir();
const plansDir = config.storage.getPlansDir();
expect(fs.promises.mkdir).not.toHaveBeenCalled();
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);
expect(fs.mkdirSync).not.toHaveBeenCalled();
const context = config.getWorkspaceContext();
expect(context.getDirectories()).not.toContain(plansDir);
});
it('should gracefully handle existing directories by relying on mkdirSync recursive: true when in PLAN mode', async () => {
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
const config = new Config({
...baseParams,
plan: true,
});
await config.initialize();
config.setApprovalMode(ApprovalMode.PLAN);
const plansDir = config.getPlansDir();
// mkdirSync should be called unconditionally
expect(fs.mkdirSync).toHaveBeenCalledWith(plansDir, { recursive: true });
// It MUST still register the directory
const context = config.getWorkspaceContext();
expect(context.getDirectories()).toContain(plansDir);
});
it('should log a warning if the plan directory path is blocked by an existing file (EEXIST) in PLAN mode', async () => {
const writeSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
vi.spyOn(fs, 'mkdirSync').mockImplementation(() => {
const err = new Error('File exists') as NodeJS.ErrnoException;
err.code = 'EEXIST';
throw err;
});
const config = new Config({
...baseParams,
plan: true,
});
await config.initialize();
config.setApprovalMode(ApprovalMode.PLAN);
config.getPlansDir();
expect(writeSpy).toHaveBeenCalledWith(
expect.stringMatching(
/Failed to initialize active plan directory.*File exists/,
),
);
writeSpy.mockRestore();
});
it('should log a warning if mkdirSync fails during getPlansDir (e.g. EACCES) in PLAN mode', async () => {
const writeSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
vi.spyOn(fs, 'mkdirSync').mockImplementation(() => {
const err = new Error('Permission denied') as NodeJS.ErrnoException;
err.code = 'EACCES';
throw err;
});
const config = new Config({
...baseParams,
plan: true,
});
await config.initialize();
config.setApprovalMode(ApprovalMode.PLAN);
config.getPlansDir();
expect(writeSpy).toHaveBeenCalledWith(
expect.stringMatching(
/Failed to initialize active plan directory.*Permission denied/,
),
);
writeSpy.mockRestore();
});
it('should NOT create plans directory or add it to workspace context when plan is disabled', async () => {
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
const config = new Config({
...baseParams,
plan: false,
@@ -3405,9 +3495,10 @@ describe('Plans Directory Initialization', () => {
await config.initialize();
const plansDir = config.storage.getPlansDir();
expect(fs.promises.mkdir).not.toHaveBeenCalledWith(plansDir, {
recursive: true,
});
expect(fs.mkdirSync).not.toHaveBeenCalled();
expect(config.getWorkspaceContext().getDirectories()).not.toContain(
plansDir,
);
});
});
+57 -14
View File
@@ -6,6 +6,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { getErrorMessage } from '../utils/errors.js';
import { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
import { inspect } from 'node:util';
import process from 'node:process';
@@ -710,6 +711,7 @@ export interface ConfigParameters {
plan?: boolean;
tracker?: boolean;
planSettings?: PlanSettings;
extensionPlanDirs?: Record<string, string>;
worktreeSettings?: WorktreeSettings;
modelSteering?: boolean;
onModelChange?: (model: string) => void;
@@ -773,6 +775,9 @@ export class Config implements McpContext, AgentLoopContext {
private readonly extensionsEnabled: boolean;
private mcpServers: Record<string, MCPServerConfig> | undefined;
private readonly mcpEnablementCallbacks?: McpEnablementCallbacks;
private activeExtensionContext?: string;
private initializedPlanDirs = new Set<string>();
private readonly extensionPlanDirs: Record<string, string>;
private userMemory: string | HierarchicalMemory;
private geminiMdFileCount: number;
private geminiMdFilePaths: string[];
@@ -1037,6 +1042,7 @@ export class Config implements McpContext, AgentLoopContext {
this.mcpServerCommand = params.mcpServerCommand;
this.mcpServers = params.mcpServers;
this.mcpEnablementCallbacks = params.mcpEnablementCallbacks;
this.extensionPlanDirs = params.extensionPlanDirs ?? {};
this.mcpEnabled = params.mcpEnabled ?? true;
this.extensionsEnabled = params.extensionsEnabled ?? true;
this.allowedMcpServers = params.allowedMcpServers ?? [];
@@ -1408,20 +1414,6 @@ export class Config implements McpContext, AgentLoopContext {
this.workspaceContext.addDirectory(dir);
}
// Add plans directory to workspace context for plan file storage
if (this.planEnabled) {
const plansDir = this.storage.getPlansDir();
try {
await fs.promises.access(plansDir);
this.workspaceContext.addDirectory(plansDir);
} catch {
// Directory does not exist yet, so we don't add it to the workspace context.
// It will be created when the first plan is written. Since custom plan
// directories must be within the project root, they are automatically
// covered by the project-wide file discovery once created.
}
}
// Initialize centralized FileDiscoveryService
const discoverToolsHandle = startupProfiler.start('discover_tools');
this.getFileService();
@@ -2251,6 +2243,57 @@ export class Config implements McpContext, AgentLoopContext {
return this.mcpEnabled;
}
getActiveExtensionContext(): string | undefined {
return this.activeExtensionContext;
}
setActiveExtensionContext(context: string | undefined): void {
this.activeExtensionContext = context;
}
hasExtensionPlanDir(name: string): boolean {
return !!this.extensionPlanDirs[name];
}
getActiveExtensionPlanDir(): string | undefined {
if (this.activeExtensionContext) {
return this.extensionPlanDirs[this.activeExtensionContext];
}
return undefined;
}
getPlansDir(): string {
const plansDir = this.storage.getPlansDir(this.getActiveExtensionPlanDir());
if (this.initializedPlanDirs.has(plansDir)) {
return plansDir;
}
if (this.planEnabled && this.isPlanMode()) {
try {
fs.mkdirSync(plansDir, { recursive: true });
let realPlansDir = plansDir;
try {
realPlansDir = resolveToRealPath(plansDir);
} catch {
// Ignore failures in mock environments
}
this.workspaceContext.addDirectory(realPlansDir);
} catch (e: unknown) {
debugLogger.error(
`Failed to initialize active plan directory at '${plansDir}': ${getErrorMessage(e)}`,
);
} finally {
this.initializedPlanDirs.add(plansDir);
}
} else if (!this.planEnabled) {
this.initializedPlanDirs.add(plansDir);
}
return plansDir;
}
getMcpEnablementCallbacks(): McpEnablementCallbacks | undefined {
return this.mcpEnablementCallbacks;
}
+41 -14
View File
@@ -10,6 +10,7 @@ import * as path from 'node:path';
import * as os from 'node:os';
import { lock } from 'proper-lockfile';
import { debugLogger } from '../utils/debugLogger.js';
import { isNodeError, getErrorMessage } from '../utils/errors.js';
export interface RegistryData {
projects: Record<string, string>;
@@ -62,7 +63,7 @@ export class ProjectRegistry {
const content = await fs.promises.readFile(this.registryPath, 'utf8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return JSON.parse(content);
} catch (e) {
} catch (e: unknown) {
debugLogger.debug('Failed to load registry: ', e);
// If the registry is corrupted, we'll start fresh to avoid blocking the CLI
return { projects: {} };
@@ -83,17 +84,37 @@ export class ProjectRegistry {
await fs.promises.mkdir(dir, { recursive: true });
}
try {
const content = JSON.stringify(data, null, 2);
// Use a randomized tmp path to avoid ENOENT crashes when save() is called concurrently
const tmpPath = this.registryPath + '.' + randomUUID() + '.tmp';
await fs.promises.writeFile(tmpPath, content, 'utf8');
await fs.promises.rename(tmpPath, this.registryPath);
} catch (error) {
debugLogger.error(
`Failed to save project registry to ${this.registryPath}:`,
error,
);
let attempt = 0;
const maxAttempts = 5;
const retryDelayMs = 100;
while (attempt < maxAttempts) {
try {
const content = JSON.stringify(data, null, 2);
// Use a randomized tmp path to avoid ENOENT crashes when save() is called concurrently
const tmpPath = this.registryPath + '.' + randomUUID() + '.tmp';
await fs.promises.writeFile(tmpPath, content, 'utf8');
await fs.promises.rename(tmpPath, this.registryPath);
return; // Success
} catch (error: unknown) {
attempt++;
const isRetryable =
isNodeError(error) &&
(error.code === 'EPERM' ||
error.code === 'EBUSY' ||
error.code === 'EACCES');
if (attempt >= maxAttempts || !isRetryable) {
debugLogger.error(
`Failed to save project registry to ${this.registryPath} after ${attempt} attempts:`,
getErrorMessage(error),
);
return; // Stop trying
} else {
// Wait before retrying (exponential backoff could be used here too)
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
}
}
}
}
@@ -177,7 +198,7 @@ export class ProjectRegistry {
if (this.normalizePath(owner) !== this.normalizePath(projectPath)) {
return false;
}
} catch (e) {
} catch (e: unknown) {
debugLogger.debug(
`Failed to read ownership marker ${markerPath}:`,
e,
@@ -220,7 +241,7 @@ export class ProjectRegistry {
}
}
}
} catch (e) {
} catch (e: unknown) {
debugLogger.debug(`Failed to scan base dir ${baseDir}:`, e);
}
}
@@ -239,6 +260,12 @@ export class ProjectRegistry {
const existingIds = new Set(Object.values(existingMappings));
while (true) {
if (counter > 1000) {
throw new Error(
'Failed to generate a unique project short ID after 1000 attempts. Check for fs mocks or filesystem permission issues.',
);
}
const candidate = counter === 0 ? slug : `${slug}-${counter}`;
counter++;
+20
View File
@@ -358,6 +358,26 @@ describe('Storage additional helpers', () => {
expected: '',
expectedError: `Custom plans directory 'symlink-to-outside' resolves to '${path.resolve('/outside/project/root')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
},
{
name: 'non-existent plan dir in a symlinked project root',
customDir: 'new-plans',
setup: () => {
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => {
const pStr = p.toString();
if (pStr === projectRoot) {
return '/private/tmp/project';
}
if (pStr.includes('new-plans')) {
const err = new Error('ENOENT') as NodeJS.ErrnoException;
err.code = 'ENOENT';
throw err;
}
return pStr;
});
return () => vi.mocked(fs.realpathSync).mockRestore();
},
expected: path.resolve(projectRoot, 'new-plans'),
},
];
testCases.forEach(({ name, customDir, expected, expectedError, setup }) => {
+17 -8
View File
@@ -18,6 +18,7 @@ import {
} from '../utils/paths.js';
import { ProjectRegistry } from './projectRegistry.js';
import { StorageMigration } from './storageMigration.js';
import { isNodeError } from '../utils/errors.js';
export const OAUTH_FILE = 'oauth_creds.json';
const TMP_DIR_NAME = 'tmp';
@@ -320,18 +321,26 @@ export class Storage {
return path.join(this.getProjectTempDir(), 'tracker');
}
getPlansDir(): string {
if (this.customPlansDir) {
const resolvedPath = path.resolve(
this.getProjectRoot(),
this.customPlansDir,
);
getPlansDir(extensionPlanDir?: string): string {
const customDir = extensionPlanDir || this.customPlansDir;
if (customDir) {
const resolvedPath = path.resolve(this.getProjectRoot(), customDir);
const realProjectRoot = resolveToRealPath(this.getProjectRoot());
const realResolvedPath = resolveToRealPath(resolvedPath);
let realResolvedPath = resolvedPath;
try {
realResolvedPath = resolveToRealPath(resolvedPath);
} catch (e: unknown) {
if (!(isNodeError(e) && (e.code === 'ENOENT' || e.code === 'EISDIR'))) {
throw e;
}
// Construct the fallback path safely against the real project root
realResolvedPath = path.resolve(realProjectRoot, customDir);
}
if (!isSubpath(realProjectRoot, realResolvedPath)) {
throw new Error(
`Custom plans directory '${this.customPlansDir}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`,
`Custom plans directory '${customDir}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`,
);
}
@@ -572,7 +572,7 @@ For example:
# Active Approval Mode: Plan
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`/tmp/project-temp/plans/\` and get user approval before editing source code.
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`/tmp/plans/\` and get user approval before editing source code.
## Available Tools
The following tools are available in Plan Mode:
@@ -588,8 +588,8 @@ The following tools are available in Plan Mode:
</available_tools>
## Rules
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/project-temp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/project-temp/plans/\`. They cannot modify source code.
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/plans/\`. They cannot modify source code.
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. Use multi-select to offer flexibility and include detailed descriptions for each option to help the user understand the implications of their choice.
4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning.
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
@@ -612,7 +612,7 @@ The depth of your consultation should be proportional to the task's complexity.
**CRITICAL:** You MUST NOT proceed to Step 3 (Draft) or Step 4 (Review & Approval) in the same turn as your initial strategy proposal. You MUST wait for user feedback and reach a clear agreement before drafting or submitting the plan.
### 3. Draft
Write the implementation plan to \`/tmp/project-temp/plans/\`. The plan's structure adapts to the task:
Write the implementation plan to \`/tmp/plans/\`. The plan's structure adapts to the task:
- **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps.
- **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**.
- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies.
+3 -1
View File
@@ -93,9 +93,10 @@ describe('Core System Prompt (prompts.ts)', () => {
getToolRegistry: vi.fn().mockReturnValue(mockRegistry),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
getProjectTempTrackerDir: vi
.fn()
.mockReturnValue('/mock/.gemini/tmp/session/tracker'),
@@ -451,6 +452,7 @@ describe('Core System Prompt (prompts.ts)', () => {
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
},
@@ -61,6 +61,7 @@ describe('PromptProvider', () => {
topicState: new TopicState(),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
+1 -1
View File
@@ -195,7 +195,7 @@ export class PromptProvider {
() => ({
interactive: interactiveMode,
planModeToolsList,
plansDir: context.config.storage.getPlansDir(),
plansDir: context.config.getPlansDir(),
approvedPlanPath: context.config.getApprovedPlanPath(),
}),
isPlanMode,
+5 -3
View File
@@ -132,9 +132,10 @@ describe('EditTool', () => {
getDisableLLMCorrection: vi.fn(() => true),
getExperiments: () => {},
isPlanMode: vi.fn(() => false),
getPlansDir: vi.fn().mockReturnValue('/tmp/project/plans'),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
getPlansDir: vi.fn().mockReturnValue('/tmp/project/plans'),
},
isPathAllowed(this: Config, absolutePath: string): boolean {
const workspaceContext = this.getWorkspaceContext();
@@ -1334,10 +1335,11 @@ function doIt() {
fs.mkdirSync(plansDir);
vi.mocked(mockConfig.isPlanMode).mockReturnValue(true);
vi.mocked(mockConfig.getPlansDir).mockReturnValue(plansDir);
vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue(plansDir);
const filePath = path.join(rootDir, 'test-file.txt');
const planFilePath = path.join(plansDir, 'test-file.txt');
const filePath = 'test-file.txt';
const planFilePath = path.join(plansDir, filePath);
const initialContent = 'some initial content';
fs.writeFileSync(planFilePath, initialContent, 'utf8');
+14 -5
View File
@@ -58,6 +58,7 @@ import { EDIT_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { detectOmissionPlaceholders } from './omissionPlaceholderDetector.js';
import { discoverJitContext, appendJitContext } from './jit-context.js';
import { resolveAndValidatePlanPath } from '../utils/planUtils.js';
const ENABLE_FUZZY_MATCH_RECOVERY = true;
const FUZZY_MATCH_THRESHOLD = 0.1; // Allow up to 10% weighted difference
@@ -465,10 +466,9 @@ class EditToolInvocation
() => this.config.getApprovalMode(),
);
if (this.config.isPlanMode()) {
const safeFilename = path.basename(this.params.file_path);
this.resolvedPath = path.join(
this.config.storage.getPlansDir(),
safeFilename,
this.resolvedPath = resolveAndValidatePlanPath(
this.params.file_path,
this.config.getPlansDir(),
);
} else if (!path.isAbsolute(this.params.file_path)) {
const result = correctPath(this.params.file_path, this.config);
@@ -1054,7 +1054,16 @@ export class EditTool
}
let resolvedPath: string;
if (!path.isAbsolute(params.file_path)) {
if (this.config.isPlanMode()) {
try {
resolvedPath = resolveAndValidatePlanPath(
params.file_path,
this.config.storage.getPlansDir(),
);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
} else if (!path.isAbsolute(params.file_path)) {
const result = correctPath(params.file_path, this.config);
if (result.success) {
resolvedPath = result.correctedPath;
@@ -11,7 +11,6 @@ import type { Config } from '../config/config.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { ToolConfirmationOutcome } from './tools.js';
import { ApprovalMode } from '../policy/types.js';
import fs from 'node:fs';
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
@@ -39,6 +38,7 @@ describe('EnterPlanModeTool', () => {
mockConfig = {
setApprovalMode: vi.fn(),
getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
storage: {
getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
} as unknown as Config['storage'],
@@ -119,7 +119,6 @@ describe('EnterPlanModeTool', () => {
describe('execute', () => {
it('should set approval mode to PLAN and return message', async () => {
const invocation = tool.build({});
vi.mocked(fs.existsSync).mockReturnValue(true);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
@@ -132,21 +131,9 @@ describe('EnterPlanModeTool', () => {
expect(result.returnDisplay).toBe('Switching to Plan mode');
});
it('should create plans directory if it does not exist', async () => {
const invocation = tool.build({});
vi.mocked(fs.existsSync).mockReturnValue(false);
await invocation.execute({ abortSignal: new AbortController().signal });
expect(fs.mkdirSync).toHaveBeenCalledWith('/mock/plans/dir', {
recursive: true,
});
});
it('should include optional reason in output display but not in llmContent', async () => {
const reason = 'Design new database schema';
const invocation = tool.build({ reason });
vi.mocked(fs.existsSync).mockReturnValue(true);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import {
BaseDeclarativeTool,
BaseToolInvocation,
@@ -20,7 +19,6 @@ import { ENTER_PLAN_MODE_TOOL_NAME } from './tool-names.js';
import { ApprovalMode } from '../policy/types.js';
import { ENTER_PLAN_MODE_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { debugLogger } from '../utils/debugLogger.js';
export interface EnterPlanModeParams {
reason?: string;
@@ -125,19 +123,6 @@ export class EnterPlanModeInvocation extends BaseToolInvocation<
this.config.setApprovalMode(ApprovalMode.PLAN);
// Ensure plans directory exists so that the agent can write the plan file.
// In sandboxed environments, the plans directory must exist on the host
// before it can be bound/allowed in the sandbox.
const plansDir = this.config.storage.getPlansDir();
if (!fs.existsSync(plansDir)) {
try {
fs.mkdirSync(plansDir, { recursive: true });
} catch (e) {
// Log error but don't fail; write_file will try again later
debugLogger.error(`Failed to create plans directory: ${plansDir}`, e);
}
}
return {
llmContent: 'Switching to Plan mode.',
returnDisplay: this.params.reason
@@ -44,6 +44,7 @@ describe('ExitPlanModeTool', () => {
getTargetDir: vi.fn().mockReturnValue(tempRootDir),
setApprovalMode: vi.fn(),
setApprovedPlanPath: vi.fn(),
getPlansDir: vi.fn().mockReturnValue(mockPlansDir),
storage: {
getPlansDir: vi.fn().mockReturnValue(mockPlansDir),
} as unknown as Config['storage'],
@@ -72,8 +73,10 @@ describe('ExitPlanModeTool', () => {
const createPlanFile = (name: string, content: string) => {
const filePath = path.join(mockPlansDir, name);
// Ensure parent directory exists for nested tests
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
return path.join('plans', name);
return name;
};
describe('shouldConfirmExecute', () => {
+25 -18
View File
@@ -19,9 +19,13 @@ 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,
resolveAndValidatePlanPath,
} from '../utils/planUtils.js';
import { ApprovalMode } from '../policy/types.js';
import { resolveToRealPath, isSubpath } from '../utils/paths.js';
// Remove unused imports
import { logPlanExecution } from '../telemetry/loggers.js';
import { PlanExecutionEvent } from '../telemetry/types.js';
import { getExitPlanModeDefinition } from './definitions/coreTools.js';
@@ -59,18 +63,19 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
if (!params.plan_filename || params.plan_filename.trim() === '') {
return 'plan_filename is required.';
}
const safeFilename = path.basename(params.plan_filename);
const plansDir = resolveToRealPath(this.config.storage.getPlansDir());
const resolvedPath = path.join(
this.config.storage.getPlansDir(),
safeFilename,
);
const realPath = resolveToRealPath(resolvedPath);
if (!isSubpath(plansDir, realPath)) {
return `Access denied: plan path (${resolvedPath}) must be within the designated plans directory (${plansDir}).`;
try {
resolveAndValidatePlanPath(
params.plan_filename,
this.config.getPlansDir(),
);
} catch (e) {
if (e instanceof Error && e.message.startsWith('Security violation')) {
return `Access denied: plan path (${path.join(
this.config.getPlansDir(),
params.plan_filename,
)}) must be within the designated plans directory (${this.config.getPlansDir()}).`;
}
return e instanceof Error ? e.message : String(e);
}
return null;
@@ -121,7 +126,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
const pathError = await validatePlanPath(
this.params.plan_filename,
this.config.storage.getPlansDir(),
this.config.getPlansDir(),
);
if (pathError) {
this.planValidationError = pathError;
@@ -171,7 +176,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
}
getDescription(): string {
return `Requesting plan approval for: ${path.join(this.config.storage.getPlansDir(), this.params.plan_filename)}`;
return `Requesting plan approval for: ${path.join(this.config.getPlansDir(), this.params.plan_filename)}`;
}
/**
@@ -179,8 +184,10 @@ 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 resolveAndValidatePlanPath(
this.params.plan_filename,
this.config.getPlansDir(),
);
}
async execute({ abortSignal: _signal }: ExecuteOptions): Promise<ToolResult> {
+1 -1
View File
@@ -634,7 +634,7 @@ export class ToolRegistry {
*/
getFunctionDeclarations(modelId?: string): FunctionDeclaration[] {
const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN;
const plansDir = this.config.storage.getPlansDir();
const plansDir = this.config.getPlansDir();
const declarations: FunctionDeclaration[] = [];
const seenNames = new Set<string>();
@@ -107,6 +107,7 @@ const mockConfigInternal = {
getDisableLLMCorrection: vi.fn(() => true),
isPlanMode: vi.fn(() => false),
getActiveModel: () => 'test-model',
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
},
+17 -5
View File
@@ -49,6 +49,7 @@ import { debugLogger } from '../utils/debugLogger.js';
import { WRITE_FILE_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { detectOmissionPlaceholders } from './omissionPlaceholderDetector.js';
import { resolveAndValidatePlanPath } from '../utils/planUtils.js';
import { isGemini3Model } from '../config/models.js';
import { discoverJitContext, appendJitContext } from './jit-context.js';
@@ -168,10 +169,9 @@ class WriteFileToolInvocation extends BaseToolInvocation<
);
if (this.config.isPlanMode()) {
const safeFilename = path.basename(this.params.file_path);
this.resolvedPath = path.join(
this.config.storage.getPlansDir(),
safeFilename,
this.resolvedPath = resolveAndValidatePlanPath(
this.params.file_path,
this.config.getPlansDir(),
);
} else {
this.resolvedPath = path.resolve(
@@ -499,7 +499,19 @@ export class WriteFileTool
return `Missing or empty "file_path"`;
}
const resolvedPath = path.resolve(this.config.getTargetDir(), filePath);
let resolvedPath: string;
if (this.config.isPlanMode()) {
try {
resolvedPath = resolveAndValidatePlanPath(
filePath,
this.config.getPlansDir(),
);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
} else {
resolvedPath = path.resolve(this.config.getTargetDir(), filePath);
}
const validationError = this.config.validatePathAccess(resolvedPath);
if (validationError) {
+60 -6
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,
resolveAndValidatePlanPath,
} from './planUtils.js';
describe('planUtils', () => {
let tempRootDir: string;
@@ -29,10 +33,60 @@ describe('planUtils', () => {
}
});
describe('resolveAndValidatePlanPath', () => {
it('should strip redundant prefix matching plansDir basename', () => {
const result = resolveAndValidatePlanPath('plans/test.md', plansDir);
expect(result).toBe(path.join(plansDir, 'test.md'));
});
it('should strip redundant prefix when path starts with ./', () => {
const result = resolveAndValidatePlanPath('./plans/test.md', plansDir);
expect(result).toBe(path.join(plansDir, 'test.md'));
});
it('should strip redundant prefix matching plansDir basename (with nested path)', () => {
const result = resolveAndValidatePlanPath(
'plans/nested/test.md',
plansDir,
);
expect(result).toBe(path.join(plansDir, 'nested/test.md'));
});
it('should handle standard paths without the prefix', () => {
const result = resolveAndValidatePlanPath('test.md', plansDir);
expect(result).toBe(path.join(plansDir, 'test.md'));
});
it('should handle standard paths with ./ prefix', () => {
const result = resolveAndValidatePlanPath('./test.md', plansDir);
expect(result).toBe(path.join(plansDir, 'test.md'));
});
it('should throw if path is empty after stripping prefix', () => {
expect(() => resolveAndValidatePlanPath('plans', plansDir)).toThrowError(
/must include a filename/,
);
expect(() => resolveAndValidatePlanPath('plans/', plansDir)).toThrowError(
/must include a filename/,
);
expect(() =>
resolveAndValidatePlanPath('./plans', plansDir),
).toThrowError(/must include a filename/);
});
it('should handle mixed separators', () => {
const result = resolveAndValidatePlanPath(
'plans\\windows-style.md',
plansDir,
);
expect(result).toBe(path.join(plansDir, 'windows-style.md'));
});
});
describe('validatePlanPath', () => {
it('should return null for a valid path within plans directory', async () => {
const planPath = path.join('plans', 'test.md');
const fullPath = path.join(tempRootDir, planPath);
const planPath = 'test.md';
const fullPath = path.join(plansDir, planPath);
fs.writeFileSync(fullPath, '# My Plan');
const result = await validatePlanPath(planPath, plansDir);
@@ -40,14 +94,14 @@ describe('planUtils', () => {
});
it('should return error for non-existent file', async () => {
const planPath = path.join('plans', 'ghost.md');
const planPath = 'ghost.md';
const result = await validatePlanPath(planPath, plansDir);
expect(result).toContain('Plan file does not exist');
});
it('should detect path traversal via symbolic links', async () => {
const maliciousPath = path.join('plans', 'malicious.md');
const fullMaliciousPath = path.join(tempRootDir, maliciousPath);
const maliciousPath = 'malicious.md';
const fullMaliciousPath = path.join(plansDir, maliciousPath);
const outsideFile = path.join(tempRootDir, 'outside.txt');
fs.writeFileSync(outsideFile, 'secret content');
+67 -14
View File
@@ -22,31 +22,84 @@ export const PlanErrorMessages = {
READ_FAILURE: (detail: string) => `Failed to read plan file: ${detail}`,
} as const;
/**
* Resolves a plan file path and strictly validates it against the plans directory boundary.
* Useful for tools that need to write or read plans.
* @param planPath The untrusted file path provided by the model.
* @param plansDir The authorized project plans directory.
* @returns The safely resolved path string.
* @throws Error if the path is empty, malicious, or escapes boundaries.
*/
export function resolveAndValidatePlanPath(
planPath: string,
plansDir: string,
): string {
const trimmedPath = planPath.trim();
if (!trimmedPath) {
throw new Error('Plan file path must be non-empty.');
}
// Normalize separators to forward slashes for easier manipulation
const normalizedInput = trimmedPath.replace(/\\/g, '/');
// Prevent redundant nesting if the agent includes the plans directory name in the path.
// E.g. plansDir='/repo/conductor', planPath='conductor/test.md' -> we want '/repo/conductor/test.md'
// Also handle './conductor/test.md' or 'conductor/nested/test.md'
let normalizedPlanPath = normalizedInput;
const plansDirName = path.basename(plansDir);
// Split into segments and remove empty or '.' segments from the beginning
const segments = normalizedInput.split('/').filter((s) => s !== '');
if (segments[0] === '.') {
segments.shift();
}
if (segments[0] === plansDirName) {
// Strip the redundant prefix.
segments.shift();
normalizedPlanPath = segments.join('/');
// If the path was EXACTLY just the directory name (e.g. "conductor"), it's invalid for a file.
if (!normalizedPlanPath) {
throw new Error('Plan file path must include a filename.');
}
}
const resolvedPath = path.resolve(plansDir, normalizedPlanPath);
const realPath = resolveToRealPath(resolvedPath);
const realPlansDir = resolveToRealPath(plansDir);
if (!isSubpath(realPlansDir, realPath)) {
throw new Error(
`Security violation: plan path (${trimmedPath}) must be within the designated plans directory (${plansDir}).`,
);
}
return resolvedPath;
}
/**
* Validates a plan file path for safety (traversal) and existence.
* @param planPath The untrusted path to the plan file.
* @param plansDir The authorized project plans directory.
* @param targetDir The current working directory (project root).
* @returns An error message if validation fails, or null if successful.
*/
export async function validatePlanPath(
planPath: string,
plansDir: string,
): Promise<string | null> {
const safeFilename = path.basename(planPath);
const resolvedPath = path.join(plansDir, safeFilename);
const realPath = resolveToRealPath(resolvedPath);
const realPlansDir = resolveToRealPath(plansDir);
if (!isSubpath(realPlansDir, realPath)) {
return PlanErrorMessages.PATH_ACCESS_DENIED(planPath, realPlansDir);
try {
const resolvedPath = resolveAndValidatePlanPath(planPath, plansDir);
if (!(await fileExists(resolvedPath))) {
return PlanErrorMessages.FILE_NOT_FOUND(planPath);
}
return null;
} catch {
return PlanErrorMessages.PATH_ACCESS_DENIED(
planPath,
resolveToRealPath(plansDir),
);
}
if (!(await fileExists(resolvedPath))) {
return PlanErrorMessages.FILE_NOT_FOUND(planPath);
}
return null;
}
/**
+5 -1
View File
@@ -70,7 +70,11 @@ if (!geminiSandbox) {
geminiSandbox = process.env.GEMINI_SANDBOX;
}
geminiSandbox = (geminiSandbox || '').toLowerCase();
if (typeof geminiSandbox === 'object' && geminiSandbox !== null) {
geminiSandbox = geminiSandbox.enabled ? 'true' : 'false';
}
geminiSandbox = (geminiSandbox || '').toString().toLowerCase();
const commandExists = (cmd) => {
const checkCommand = os.platform() === 'win32' ? 'where' : 'command -v';