Compare commits

...

10 Commits

Author SHA1 Message Date
Mahima Shanware 765699e1ec perf(core): optimize plan directory resolution with LRUCache and cached project root
This commit addresses the final performance and usability review comments:

- **Performance:** Introduced `LRUCache` for `plansDirCache` and `initializedPlanDirs` to prevent redundant, synchronous filesystem calls to `Storage.getPlansDir` on every turn.
- **Performance:** Cached the resolved `realProjectRoot` in the `Storage` constructor, eliminating expensive synchronous symlink resolution calls during active command routing.
- **Usability:** Replaced hard `throw` with `console.warn` when `fs.mkdirSync` fails (e.g., `EACCES`, `EEXIST`), allowing the CLI to gracefully degrade and continue functioning rather than crashing the entire process.
- **Validation:** Updated `config.test.ts` to verify the exact warning messages emitted during filesystem failures.
2026-04-06 21:30:04 +00:00
Mahima Shanware 79cc27439d fix(core,cli): address review findings for plan dir resolution and security
This commit addresses several critical findings from the review bot:

- **Security:** Implemented defense-in-depth symlink resolution. Removed insecure string-based fallbacks in `Storage.getPlansDir` and added a mandatory `isSubpath` validation AFTER directory creation in `Config.getPlansDir` to prevent TOCTOU traversal attacks.
- **Architecture:** Fixed a race condition where active extension context was mutated synchronously in `AppContainer`, potentially corrupting concurrent background tasks. Mutation now occurs within the command execution pipeline.
- **Robustness:** Switched to canonical path checking for `plan` command detection to support aliases and subcommands.
- **Regressions:** Added a `planEnabled` guard to prevent unwanted directory creation when the planning feature is disabled.
- **Validation:** Added exhaustive unit tests covering sequential context switching, shared directory deduplication, and symlink security edge cases.
2026-04-06 21:09:47 +00:00
Mahima Shanware e99b47c22e fix(core): remove redundant ENOENT fallback in getPlansDir to fix traversal vulnerability
This removes the insecure ENOENT fallback in `Storage.getPlansDir` that could be exploited to bypass the `isSubpath` check via symlinks. The fallback was unnecessary because the underlying `resolveToRealPath` function (via `robustRealpath`) was recently updated to gracefully handle and resolve symlinks for non-existent target paths.
2026-04-06 20:32:12 +00:00
Mahima Shanware 4195168d4d 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-06 19:36:05 +00:00
Mahima Shanware 9d600b9e8f perf(core): cache initialized plan directories
Adds caching to getPlansDir to avoid redundant synchronous disk I/O and repeated workspace context registrations.
2026-04-06 19:13:49 +00:00
Mahima Shanware 1ed9a04d71 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-06 18:45:06 +00:00
Mahima Shanware c578567488 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-06 17:41:56 +00:00
Mahima Shanware 39a7d59b27 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-06 16:56:01 +00:00
Mahima Shanware 985c5953c6 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-06 16:56:01 +00:00
Mahima Shanware 625b53ef39 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-06 16:56:01 +00:00
27 changed files with 441 additions and 109 deletions
+2 -1
View File
@@ -3711,7 +3711,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
@@ -609,9 +609,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;
@@ -969,9 +972,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),
@@ -3182,9 +3182,7 @@ describe('AppContainer State Management', () => {
);
unmount();
});
});
describe('Overflow Hint Handling', () => {
beforeEach(() => {
vi.useFakeTimers();
});
+6 -4
View File
@@ -1308,6 +1308,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}
const parsedCommand = parseSlashCommand(
submittedValue,
slashCommands ?? [],
);
const isSlash = isSlashCommand(submittedValue.trim());
const isIdle = streamingState === StreamingState.Idle;
const isAgentRunning =
@@ -1315,10 +1320,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
@@ -993,4 +993,155 @@ describe('useSlashCommandProcessor', () => {
expect(result.current.slashCommands).toEqual([newCommand]),
);
});
describe('Active Extension Context Switching', () => {
it('sets active extension context when a command with a plan dir is executed', async () => {
const extensionCommand = createTestCommand({
name: 'conductor:setup',
extensionName: 'conductor',
action: vi.fn(),
});
const spyHasPlanDir = vi
.spyOn(mockConfig, 'hasExtensionPlanDir')
.mockReturnValue(true);
const spySetContext = vi.spyOn(mockConfig, 'setActiveExtensionContext');
const hook = await setupProcessorHook({
builtinCommands: [extensionCommand],
});
await waitFor(() => expect(hook.current.slashCommands!.length).toBe(1));
await act(async () => {
await hook.current.handleSlashCommand('/conductor:setup');
});
expect(spyHasPlanDir).toHaveBeenCalledWith('conductor');
expect(spySetContext).toHaveBeenCalledWith('conductor');
});
it('clears active extension context when a command WITHOUT a plan dir is executed', async () => {
const extensionCommand = createTestCommand({
name: 'other:cmd',
extensionName: 'other',
action: vi.fn(),
});
const spyHasPlanDir = vi
.spyOn(mockConfig, 'hasExtensionPlanDir')
.mockReturnValue(false);
const spySetContext = vi.spyOn(mockConfig, 'setActiveExtensionContext');
const hook = await setupProcessorHook({
builtinCommands: [extensionCommand],
});
await waitFor(() => expect(hook.current.slashCommands!.length).toBe(1));
await act(async () => {
await hook.current.handleSlashCommand('/other:cmd');
});
expect(spyHasPlanDir).toHaveBeenCalledWith('other');
expect(spySetContext).toHaveBeenCalledWith(undefined);
});
it('clears active extension context when the canonical /plan command is executed', async () => {
const planCommand = createTestCommand({
name: 'plan',
action: vi.fn(),
});
const spySetContext = vi.spyOn(mockConfig, 'setActiveExtensionContext');
const hook = await setupProcessorHook({
builtinCommands: [planCommand],
});
await waitFor(() => expect(hook.current.slashCommands!.length).toBe(1));
await act(async () => {
await hook.current.handleSlashCommand('/plan my task');
});
expect(spySetContext).toHaveBeenCalledWith(undefined);
});
it('clears active extension context when a /plan alias or subcommand is executed', async () => {
const planCommand = createTestCommand({
name: 'plan',
subCommands: [
createTestCommand({
name: 'create',
}),
],
action: vi.fn(),
});
const spySetContext = vi.spyOn(mockConfig, 'setActiveExtensionContext');
const hook = await setupProcessorHook({
builtinCommands: [planCommand],
});
await waitFor(() => expect(hook.current.slashCommands!.length).toBe(1));
await act(async () => {
await hook.current.handleSlashCommand('/plan create');
});
expect(spySetContext).toHaveBeenCalledWith(undefined);
});
it('handles a sequence of context switches between extensions and default plan mode', async () => {
const extA = createTestCommand({
name: 'extA',
extensionName: 'extA',
action: vi.fn(),
});
const extB = createTestCommand({
name: 'extB',
extensionName: 'extB',
action: vi.fn(),
});
const planCmd = createTestCommand({
name: 'plan',
action: vi.fn(),
});
const spySetContext = vi.spyOn(mockConfig, 'setActiveExtensionContext');
vi.spyOn(mockConfig, 'hasExtensionPlanDir').mockReturnValue(true);
const hook = await setupProcessorHook({
builtinCommands: [extA, extB, planCmd],
});
await waitFor(() => expect(hook.current.slashCommands!.length).toBe(3));
// 1. Run Ext A
await act(async () => {
await hook.current.handleSlashCommand('/extA');
});
expect(spySetContext).toHaveBeenLastCalledWith('extA');
// 2. Run Ext B
await act(async () => {
await hook.current.handleSlashCommand('/extB');
});
expect(spySetContext).toHaveBeenLastCalledWith('extB');
// 3. Run /plan (Default)
await act(async () => {
await hook.current.handleSlashCommand('/plan my task');
});
expect(spySetContext).toHaveBeenLastCalledWith(undefined);
// 4. Run /clear (Global)
await act(async () => {
await hook.current.handleSlashCommand('/help');
});
// Context should still be undefined
expect(spySetContext).toHaveBeenLastCalledWith(undefined);
});
});
});
@@ -368,8 +368,21 @@ export const useSlashCommandProcessor = (
commandToExecute,
args,
canonicalPath: resolvedCommandPath,
extensionContext,
} = parseSlashCommand(trimmed, commands);
if (config) {
if (extensionContext) {
if (config.hasExtensionPlanDir(extensionContext)) {
config.setActiveExtensionContext(extensionContext);
} else {
config.setActiveExtensionContext(undefined);
}
} else if (resolvedCommandPath?.[0] === 'plan') {
config.setActiveExtensionContext(undefined);
}
}
// If the input doesn't match any known command, check if MCP servers
// are still loading (the command might come from an MCP server).
// Otherwise, treat it as regular text input (e.g. file paths like
+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 };
};
+121 -18
View File
@@ -3266,6 +3266,9 @@ describe('Plans Directory Initialization', () => {
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
planSettings: {
directory: 'plans',
},
};
beforeEach(() => {
@@ -3274,11 +3277,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,
@@ -3286,18 +3290,99 @@ 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', async () => {
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
const config = new Config({
...baseParams,
plan: true,
});
await config.initialize();
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 gracefully handle existing directories by relying on mkdirSync recursive: true', async () => {
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
const config = new Config({
...baseParams,
plan: true,
});
await config.initialize();
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)', async () => {
const warnSpy = vi.spyOn(console, 'warn').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.getPlansDir();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringMatching(
/Failed to initialize active plan directory.*File exists/,
),
);
warnSpy.mockRestore();
});
it('should log a warning if mkdirSync fails during getPlansDir (e.g. EACCES)', async () => {
const warnSpy = vi.spyOn(console, 'warn').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.getPlansDir();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringMatching(
/Failed to initialize active plan directory.*Permission denied/,
),
);
warnSpy.mockRestore();
});
it('should deduplicate and cache when multiple extensions (or default) use the same directory', async () => {
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
const config = new Config({
...baseParams,
plan: true,
@@ -3305,15 +3390,31 @@ describe('Plans Directory Initialization', () => {
await config.initialize();
const plansDir = config.storage.getPlansDir();
expect(fs.promises.mkdir).not.toHaveBeenCalled();
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);
// 1. Call for Default Plan Dir
const defaultDir = config.getPlansDir();
expect(fs.mkdirSync).toHaveBeenCalledTimes(1);
const context = config.getWorkspaceContext();
expect(context.getDirectories()).not.toContain(plansDir);
// 2. Mock an extension that happens to use the SAME directory string
vi.spyOn(
config as unknown as {
getActiveExtensionPlanDir: () => string | undefined;
},
'getActiveExtensionPlanDir',
).mockReturnValue(
'plans', // This will resolve to the same path as the default in our mock setup
);
const extDir = config.getPlansDir();
// It should be the same path
expect(extDir).toBe(defaultDir);
// It should NOT have called mkdirSync a second time
expect(fs.mkdirSync).toHaveBeenCalledTimes(1);
});
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,
@@ -3321,10 +3422,12 @@ describe('Plans Directory Initialization', () => {
await config.initialize();
const plansDir = config.storage.getPlansDir();
expect(fs.promises.mkdir).not.toHaveBeenCalledWith(plansDir, {
recursive: true,
});
// Even if getPlansDir is called manually, it should NOT create the directory
const plansDir = config.getPlansDir();
expect(fs.mkdirSync).not.toHaveBeenCalled();
expect(config.getWorkspaceContext().getDirectories()).not.toContain(
plansDir,
);
});
});
+57 -14
View File
@@ -462,6 +462,7 @@ import { A2AClientManager } from '../agents/a2a-client-manager.js';
import { type McpContext } from '../tools/mcp-client.js';
import type { EnvironmentSanitizationConfig } from '../services/environmentSanitization.js';
import { getErrorMessage } from '../utils/errors.js';
import { LRUCache } from 'mnemonist';
export type { FileFilteringOptions };
export {
@@ -716,6 +717,7 @@ export interface ConfigParameters {
plan?: boolean;
tracker?: boolean;
planSettings?: PlanSettings;
extensionPlanDirs?: Record<string, string>;
worktreeSettings?: WorktreeSettings;
modelSteering?: boolean;
onModelChange?: (model: string) => void;
@@ -779,6 +781,10 @@ 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 LRUCache<string, boolean>(20);
private plansDirCache = new LRUCache<string | undefined, string>(20);
private readonly extensionPlanDirs: Record<string, string>;
private userMemory: string | HierarchicalMemory;
private geminiMdFileCount: number;
private geminiMdFilePaths: string[];
@@ -1030,6 +1036,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 ?? [];
@@ -1391,20 +1398,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();
@@ -2215,6 +2208,56 @@ 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 context = this.getActiveExtensionContext();
// Cache key: undefined means default context, string means extension context
const cacheKey = context === undefined ? 'default' : context;
let plansDir = this.plansDirCache.get(cacheKey);
if (plansDir === undefined) {
plansDir = this.storage.getPlansDir(this.getActiveExtensionPlanDir());
this.plansDirCache.set(cacheKey, plansDir);
}
if (!this.planEnabled || this.initializedPlanDirs.has(plansDir)) {
return plansDir;
}
try {
fs.mkdirSync(plansDir, { recursive: true });
const realPlansDir = resolveToRealPath(plansDir);
this.workspaceContext.addDirectory(realPlansDir);
this.initializedPlanDirs.set(plansDir, true);
} catch (e: unknown) {
const errorMessage = e instanceof Error ? e.message : String(e);
// eslint-disable-next-line no-console
console.warn(
`Failed to initialize active plan directory at '${plansDir}': ${errorMessage}`,
);
}
return plansDir;
}
getMcpEnablementCallbacks(): McpEnablementCallbacks | undefined {
return this.mcpEnablementCallbacks;
}
+42
View File
@@ -358,6 +358,48 @@ describe('Storage additional helpers', () => {
expectedError:
"Custom plans directory 'symlink-to-outside' resolves to '/outside/project/root', which is outside the project root '/tmp/project'.",
},
{
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'),
},
{
name: 'security escape via symbolic link with non-existent dir throws',
customDir: 'link-to-outside/new-dir',
setup: () => {
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => {
const pStr = p.toString();
if (pStr.includes('link-to-outside/new-dir')) {
const err = new Error('ENOENT') as NodeJS.ErrnoException;
err.code = 'ENOENT';
throw err;
}
if (pStr.includes('link-to-outside')) {
return '/outside/project/root';
}
return pStr;
});
return () => vi.mocked(fs.realpathSync).mockRestore();
},
expected: '',
expectedError:
"Custom plans directory 'link-to-outside/new-dir' resolves to '/outside/project/root/new-dir', which is outside the project root '/tmp/project'.",
},
];
testCases.forEach(({ name, customDir, expected, expectedError, setup }) => {
+8 -9
View File
@@ -32,10 +32,12 @@ export class Storage {
private projectIdentifier: string | undefined;
private initPromise: Promise<void> | undefined;
private customPlansDir: string | undefined;
private readonly realProjectRoot: string;
constructor(targetDir: string, sessionId?: string) {
this.targetDir = targetDir;
this.sessionId = sessionId;
this.realProjectRoot = resolveToRealPath(targetDir);
}
setCustomPlansDir(dir: string | undefined): void {
@@ -313,18 +315,15 @@ export class Storage {
return path.join(this.getProjectTempDir(), 'tracker');
}
getPlansDir(): string {
if (this.customPlansDir) {
const resolvedPath = path.resolve(
this.getProjectRoot(),
this.customPlansDir,
);
const realProjectRoot = resolveToRealPath(this.getProjectRoot());
getPlansDir(extensionPlanDir?: string): string {
const customDir = extensionPlanDir || this.customPlansDir;
if (customDir) {
const resolvedPath = path.resolve(this.getProjectRoot(), customDir);
const realResolvedPath = resolveToRealPath(resolvedPath);
if (!isSubpath(realProjectRoot, realResolvedPath)) {
if (!isSubpath(this.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 '${this.realProjectRoot}'.`,
);
}
@@ -567,7 +567,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:
@@ -583,8 +583,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.
@@ -607,7 +607,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
@@ -90,9 +90,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'),
},
isInteractive: vi.fn().mockReturnValue(true),
isInteractiveShellEnabled: vi.fn().mockReturnValue(true),
@@ -420,6 +421,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
@@ -178,7 +178,7 @@ export class PromptProvider {
() => ({
interactive: interactiveMode,
planModeToolsList,
plansDir: context.config.storage.getPlansDir(),
plansDir: context.config.getPlansDir(),
approvedPlanPath: context.config.getApprovedPlanPath(),
taskTracker: context.config.isTrackerEnabled(),
}),
+3 -1
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();
@@ -1314,6 +1315,7 @@ 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');
+1 -4
View File
@@ -465,10 +465,7 @@ class EditToolInvocation
);
if (this.config.isPlanMode()) {
const safeFilename = path.basename(this.params.file_path);
this.resolvedPath = path.join(
this.config.storage.getPlansDir(),
safeFilename,
);
this.resolvedPath = path.join(this.config.getPlansDir(), safeFilename);
} else if (!path.isAbsolute(this.params.file_path)) {
const result = correctPath(this.params.file_path, this.config);
if (result.success) {
@@ -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(new AbortController().signal);
@@ -130,21 +129,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(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(new AbortController().signal);
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import {
BaseDeclarativeTool,
BaseToolInvocation,
@@ -19,7 +18,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;
@@ -124,19 +122,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'],
+5 -8
View File
@@ -60,11 +60,8 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
}
const safeFilename = path.basename(params.plan_filename);
const plansDir = resolveToRealPath(this.config.storage.getPlansDir());
const resolvedPath = path.join(
this.config.storage.getPlansDir(),
safeFilename,
);
const plansDir = resolveToRealPath(this.config.getPlansDir());
const resolvedPath = path.join(this.config.getPlansDir(), safeFilename);
const realPath = resolveToRealPath(resolvedPath);
@@ -120,7 +117,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;
@@ -170,7 +167,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,7 +176,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
*/
private getResolvedPlanPath(): string {
const safeFilename = path.basename(this.params.plan_filename);
return path.join(this.config.storage.getPlansDir(), safeFilename);
return path.join(this.config.getPlansDir(), safeFilename);
}
async execute(_signal: AbortSignal): Promise<ToolResult> {
+1 -1
View File
@@ -617,7 +617,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'),
},
+1 -4
View File
@@ -168,10 +168,7 @@ 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 = path.join(this.config.getPlansDir(), safeFilename);
} else {
this.resolvedPath = path.resolve(
this.config.getTargetDir(),