Compare commits

...

22 Commits

Author SHA1 Message Date
Mahima Shanware 9ff03ddd20 fix(core): remove redundant plansDirCache to avoid stale configs and satisfy bot 2026-04-07 16:53:10 +00:00
Mahima Shanware 50d8880e9c fix(core): remove lint disablers in plan directory validation
Replaced eslint-disable-next-line comments with explicit type guarding (checking instanceof Error and 'code') and used process.stderr.write instead of console.warn to comply with project linting rules natively.
2026-04-07 16:40:32 +00:00
Mahima Shanware 495931be79 fix(core): remove duplicate isSubpath import
Moved the isSubpath and resolveToRealPath imports to the top of the file to satisfy the static analysis bot, which failed to detect the existing import statement further down in the file.
2026-04-07 16:40:31 +00:00
Mahima Shanware df8f1a8f03 fix: address review bot feedback on concurrency and error handling
- Reverted the 'isIdle' guard in AppContainer.tsx to ensure slash commands entered while the agent is busy are correctly processed or queued, preventing them from falling through as regular chat text.
- Enhanced the physical path validation in config.ts to gracefully handle 'mkdirSync' failures (e.g. EACCES). The CLI will now log a warning and return the lexically-validated path instead of throwing a misleading 'Security violation' via 'resolveToRealPath'.
2026-04-07 16:40:31 +00:00
Mahima Shanware 7c0cfd53df fix(core): gracefully degrade global config dir path validation on EACCES
If the user's home directory or global `.gemini` config directory has
restrictive permissions that prevent `realpathSync` from succeeding, the
CLI should not crash. Instead, it now gracefully degrades by omitting
the global config directory from the valid security boundaries for plan
directories, allowing the CLI to continue operating securely within the
project root.
2026-04-07 16:40:31 +00:00
Mahima Shanware 432df7982c fix: address final review feedback on context resets and pre-creation path validation 2026-04-07 16:40:31 +00:00
Mahima Shanware 35fd166bce fix(core): ensure global gemini dir resolution is crash-safe during plan validation 2026-04-07 16:40:31 +00:00
Mahima Shanware e42b762553 fix(core): ensure path validation always executes even if mkdirSync fails 2026-04-07 16:40:31 +00:00
Mahima Shanware 76c28142eb fix(cli): remove /plan extension context hacks and fix dropped queue messages 2026-04-07 16:40:31 +00:00
Mahima Shanware 7ab4c3d61f fix(core,cli): resolve TOCTOU, concurrency, and performance regressions in plan resolution 2026-04-07 16:40:31 +00:00
Mahima Shanware ded474c2d0 fix(core): fail-closed security for plan directory TOCTOU
Resolves security review findings:
- Reordered resolveToRealPath before mkdirSync to fully eliminate TOCTOU risks with symlink injection.
- Fail closed by re-throwing 'Security violation' errors instead of swallowing them.
- Replaced lint-disabler with process.stderr.write for legitimate fallback warnings.
- Used direct context string as LRUCache key to avoid collision with an extension potentially named 'default'.
2026-04-07 16:35:24 +00:00
Mahima Shanware 3256b16039 fix(core): mitigate TOCTOU vulnerability in plan directory creation
This change addresses a critical security review finding regarding a Time-of-Check to Time-of-Use (TOCTOU) vulnerability.

Previously, plan directory paths were validated using `isSubpath` before creation. However, an attacker could potentially replace a path component with a symlink pointing outside the project root exactly between validation and creation.

By resolving the physical path *after* `fs.mkdirSync` using `resolveToRealPath` and then verifying it with `isSubpath`, we ensure that the actual directory created on disk resides safely within the workspace. Any violation results in a warning, and the malicious path is prevented from being added to the agent's `workspaceContext`.
2026-04-07 16:35:24 +00:00
Mahima Shanware 5e89760856 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-07 16:35:24 +00:00
Mahima Shanware 6559fdbc31 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-07 16:35:24 +00:00
Mahima Shanware a5c2bf81f4 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-07 16:35:24 +00:00
Mahima Shanware b5d92caf89 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-07 16:35:23 +00:00
Mahima Shanware 81c74e1483 perf(core): cache initialized plan directories
Adds caching to getPlansDir to avoid redundant synchronous disk I/O and repeated workspace context registrations.
2026-04-07 16:35:23 +00:00
Mahima Shanware b2f7c157ce 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-07 16:35:23 +00:00
Mahima Shanware 0a8195fb3a 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-07 16:35:23 +00:00
Mahima Shanware 058b5e31b4 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-07 16:35:23 +00:00
Mahima Shanware 402a96a519 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-07 16:35:23 +00:00
Mahima Shanware bdf90e9985 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-07 16:34:08 +00:00
34 changed files with 493 additions and 141 deletions
+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),
@@ -3195,9 +3195,7 @@ describe('AppContainer State Management', () => {
);
unmount();
});
});
describe('Overflow Hint Handling', () => {
beforeEach(() => {
vi.useFakeTimers();
});
+2 -1
View File
@@ -1347,10 +1347,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
isToolExecuting(pendingHistoryItems);
if (isSlash && isAgentRunning) {
const { commandToExecute } = parseSlashCommand(
const parsedCommand = 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
@@ -56,8 +56,9 @@ describe('planCommand', () => {
config: {
isPlanEnabled: vi.fn(),
setApprovalMode: vi.fn(),
getApprovedPlanPath: vi.fn(),
getApprovalMode: vi.fn(),
getApprovedPlanPath: vi.fn(),
getPlansDir: vi.fn(),
getFileSystemService: vi.fn(),
storage: {
getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
+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);
@@ -993,4 +993,103 @@ 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('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 /help (Concurrent global command)
spySetContext.mockClear();
await act(async () => {
await hook.current.handleSlashCommand('/help');
});
// A concurrent command should NOT modify the active extension context
expect(spySetContext).not.toHaveBeenCalled();
});
});
});
@@ -368,8 +368,17 @@ export const useSlashCommandProcessor = (
commandToExecute,
args,
canonicalPath: resolvedCommandPath,
extensionContext,
} = parseSlashCommand(trimmed, commands);
if (config && commandToExecute && !commandToExecute.isSafeConcurrent) {
config.setActiveExtensionContext(
extensionContext && config.hasExtensionPlanDir(extensionContext)
? extensionContext
: 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 };
};
View File
View File
+165 -18
View File
@@ -3275,6 +3275,9 @@ describe('Plans Directory Initialization', () => {
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
planSettings: {
directory: 'plans',
},
};
beforeEach(() => {
@@ -3283,11 +3286,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,
@@ -3295,18 +3299,84 @@ 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 writeSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
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(writeSpy).toHaveBeenCalledWith(
expect.stringMatching(
/Failed to initialize active plan directory.*File exists/,
),
);
writeSpy.mockRestore();
});
it('should throw a security violation and verify mkdirSync runs before realpathSync (TOCTOU mitigation)', async () => {
const callOrder: string[] = [];
vi.spyOn(fs, 'mkdirSync').mockImplementation(() => {
callOrder.push('mkdirSync');
return undefined;
});
const config = new Config({
...baseParams,
plan: true,
@@ -3314,15 +3384,90 @@ describe('Plans Directory Initialization', () => {
await config.initialize();
const plansDir = config.storage.getPlansDir();
expect(fs.promises.mkdir).not.toHaveBeenCalled();
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);
// Bypass Storage check so we can specifically test Config's check
vi.spyOn(config.storage, 'getPlansDir').mockReturnValue('/tmp/test/plans');
const context = config.getWorkspaceContext();
expect(context.getDirectories()).not.toContain(plansDir);
const realpathSpy = vi
.spyOn(fs, 'realpathSync')
.mockImplementation((p: fs.PathLike) => {
const pStr = p.toString();
// Ignore the calls from storage/initialization
if (pStr.includes('plans')) {
callOrder.push('realpathSync');
return '/outside/the/project/root/plans';
}
return pStr;
});
try {
expect(() => config.getPlansDir()).toThrow(
/Security violation: Resolved plan directory.*is outside both the project root.*and the global configuration directory/,
);
expect(callOrder).toEqual(['mkdirSync', 'realpathSync']);
} finally {
realpathSpy.mockRestore();
}
});
it('should log a warning if mkdirSync fails during getPlansDir (e.g. EACCES)', async () => {
const writeSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
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(writeSpy).toHaveBeenCalledWith(
expect.stringMatching(
/Failed to initialize active plan directory.*Permission denied/,
),
);
writeSpy.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,
});
await config.initialize();
// 1. Call for Default Plan Dir
const defaultDir = config.getPlansDir();
expect(fs.mkdirSync).toHaveBeenCalledTimes(1);
// 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,
@@ -3330,10 +3475,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,
);
});
});
+130 -15
View File
@@ -6,6 +6,9 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { LRUCache } from 'mnemonist';
import { SecurityError } from '../core/errors.js';
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
import { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
import { inspect } from 'node:util';
import process from 'node:process';
@@ -168,7 +171,6 @@ import { SkillManager, type SkillDefinition } from '../skills/skillManager.js';
import { startupProfiler } from '../telemetry/startupProfiler.js';
import type { AgentDefinition } from '../agents/types.js';
import { fetchAdminControls } from '../code_assist/admin/admin_controls.js';
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
import { InjectionService } from './injectionService.js';
import { ExecutionLifecycleService } from '../services/executionLifecycleService.js';
import { WORKSPACE_POLICY_TIER } from '../policy/config.js';
@@ -710,6 +712,7 @@ export interface ConfigParameters {
plan?: boolean;
tracker?: boolean;
planSettings?: PlanSettings;
extensionPlanDirs?: Record<string, string>;
worktreeSettings?: WorktreeSettings;
modelSteering?: boolean;
onModelChange?: (model: string) => void;
@@ -773,6 +776,16 @@ export class Config implements McpContext, AgentLoopContext {
private readonly extensionsEnabled: boolean;
private mcpServers: Record<string, MCPServerConfig> | undefined;
private readonly mcpEnablementCallbacks?: McpEnablementCallbacks;
/**
* The persistent context used to route tools (e.g. plans, tasks) to the correct
* extension directory.
* This is a persistent session state (similar to `approvalMode`). It remains active
* across multiple LLM turns until explicitly changed by another command.
*/
private activeExtensionContext?: string;
private initializedPlanDirs = new Set<string>();
private globalGeminiDirCache = new LRUCache<string, string | undefined>(1);
private readonly extensionPlanDirs: Record<string, string>;
private userMemory: string | HierarchicalMemory;
private geminiMdFileCount: number;
private geminiMdFilePaths: string[];
@@ -1036,6 +1049,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 ?? [];
@@ -1405,20 +1419,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();
@@ -2238,6 +2238,121 @@ 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 {
const context = this.getActiveExtensionContext();
if (context) {
return this.extensionPlanDirs[context];
}
return undefined;
}
getPlansDir(): string {
const plansDir = this.storage.getPlansDir(this.getActiveExtensionPlanDir());
if (this.initializedPlanDirs.has(plansDir)) {
return plansDir;
}
const realProjectRoot = this.storage.getRealProjectRoot();
let realGlobalGeminiDir = this.globalGeminiDirCache.get('default');
if (!this.globalGeminiDirCache.has('default')) {
try {
realGlobalGeminiDir = resolveToRealPath(Storage.getGlobalGeminiDir());
} catch {
// If we can't securely resolve the global config dir (e.g. strict EACCES permissions on ~/),
// we gracefully degrade by not allowing it as a valid security boundary for plans.
realGlobalGeminiDir = undefined;
}
this.globalGeminiDirCache.set('default', realGlobalGeminiDir);
}
// 1. Lexical security check (before any filesystem mutation or return)
// We compare purely unresolved paths here to avoid static analyzer warnings about mixing resolved and unresolved paths.
// The physical security check happens AFTER mkdirSync.
const unresolvedProjectRoot = path.resolve(this.storage.getProjectRoot());
const unresolvedGlobalDir = path.resolve(Storage.getGlobalGeminiDir());
const unresolvedPlansDir = path.resolve(plansDir);
if (
!isSubpath(unresolvedProjectRoot, unresolvedPlansDir) &&
!isSubpath(unresolvedGlobalDir, unresolvedPlansDir)
) {
throw new SecurityError(
`Security violation: Plan directory '${unresolvedPlansDir}' is outside both the project root '${unresolvedProjectRoot}' and the global configuration directory.`,
);
}
// 2. We only attempt to physically create the directory if plan mode is enabled
if (this.planEnabled) {
let mkdirError: unknown;
try {
fs.mkdirSync(plansDir, { recursive: true });
} catch (e: unknown) {
mkdirError = e;
}
// 3. Physical security check (after creation, to mitigate TOCTOU symlink attacks)
let realPlansDir: string;
try {
realPlansDir = resolveToRealPath(plansDir);
} catch (e: unknown) {
if (
mkdirError &&
e instanceof Error &&
'code' in e &&
e.code === 'ENOENT'
) {
const errorMessage =
mkdirError instanceof Error
? mkdirError.message
: String(mkdirError);
process.stderr.write(
`Failed to initialize active plan directory at '${plansDir}': ${errorMessage}\n`,
);
this.initializedPlanDirs.add(plansDir);
return plansDir;
}
throw new SecurityError(
`Security violation: Could not securely resolve plan directory '${plansDir}'. System error: ${e instanceof Error ? e.message : String(e)}`,
);
}
if (
!isSubpath(realProjectRoot, realPlansDir) &&
(!realGlobalGeminiDir || !isSubpath(realGlobalGeminiDir, realPlansDir))
) {
throw new SecurityError(
`Security violation: Resolved plan directory '${realPlansDir}' is outside both the project root '${realProjectRoot}' and the global configuration directory.`,
);
}
if (mkdirError) {
const errorMessage =
mkdirError instanceof Error ? mkdirError.message : String(mkdirError);
process.stderr.write(
`Failed to initialize active plan directory at '${plansDir}': ${errorMessage}\n`,
);
} else {
this.workspaceContext.addDirectory(realPlansDir);
}
}
this.initializedPlanDirs.add(plansDir);
return plansDir;
}
getMcpEnablementCallbacks(): McpEnablementCallbacks | undefined {
return this.mcpEnablementCallbacks;
}
+18 -22
View File
@@ -24,7 +24,7 @@ vi.mock('fs', async (importOriginal) => {
});
import { Storage } from './storage.js';
import { GEMINI_DIR, homedir, resolveToRealPath } from '../utils/paths.js';
import { GEMINI_DIR, homedir } from '../utils/paths.js';
import { ProjectRegistry } from './projectRegistry.js';
import { StorageMigration } from './storageMigration.js';
@@ -103,8 +103,8 @@ describe('Storage - Security', () => {
});
describe('Storage additional helpers', () => {
const projectRoot = resolveToRealPath(path.resolve('/tmp/project'));
const storage = new Storage(projectRoot);
const projectRoot = path.resolve('/tmp/project');
let storage = new Storage(projectRoot);
beforeEach(() => {
ProjectRegistry.prototype.getShortId = vi
@@ -306,12 +306,6 @@ describe('Storage additional helpers', () => {
customDir: '.my-plans',
expected: path.resolve(projectRoot, '.my-plans'),
},
{
name: 'custom absolute path outside throws',
customDir: path.resolve('/absolute/path/to/plans'),
expected: '',
expectedError: `Custom plans directory '${path.resolve('/absolute/path/to/plans')}' resolves to '${path.resolve('/absolute/path/to/plans')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
},
{
name: 'absolute path that happens to be inside project root',
customDir: path.join(projectRoot, 'internal-plans'),
@@ -332,37 +326,39 @@ describe('Storage additional helpers', () => {
customDir: undefined,
expected: () => storage.getProjectTempPlansDir(),
},
{
name: 'escaping relative path throws',
customDir: '../escaped-plans',
expected: '',
expectedError: `Custom plans directory '../escaped-plans' resolves to '${resolveToRealPath(path.resolve(projectRoot, '../escaped-plans'))}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
},
{
name: 'hidden directory starting with ..',
customDir: '..plans',
expected: path.resolve(projectRoot, '..plans'),
},
{
name: 'security escape via symbolic link throws',
customDir: 'symlink-to-outside',
name: 'non-existent plan dir in a symlinked project root',
customDir: 'new-plans',
setup: () => {
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => {
if (p.toString().includes('symlink-to-outside')) {
return path.resolve('/outside/project/root');
const pStr = p.toString();
if (pStr === projectRoot) {
return '/private/tmp/project';
}
return p.toString();
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: '',
expectedError: `Custom plans directory 'symlink-to-outside' resolves to '${path.resolve('/outside/project/root')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
expected: path.resolve(projectRoot, 'new-plans'),
},
];
testCases.forEach(({ name, customDir, expected, expectedError, setup }) => {
it(`should handle ${name}`, async () => {
const cleanup = setup?.();
if (setup) {
storage = new Storage(projectRoot, 'test-session');
}
try {
if (name.includes('default behavior')) {
await storage.initialize();
+10 -18
View File
@@ -12,13 +12,11 @@ import {
GEMINI_DIR,
homedir,
GOOGLE_ACCOUNTS_FILENAME,
isSubpath,
resolveToRealPath,
normalizePath,
} from '../utils/paths.js';
import { ProjectRegistry } from './projectRegistry.js';
import { StorageMigration } from './storageMigration.js';
export const OAUTH_FILE = 'oauth_creds.json';
const TMP_DIR_NAME = 'tmp';
const BIN_DIR_NAME = 'bin';
@@ -32,10 +30,16 @@ 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);
}
getRealProjectRoot(): string {
return this.realProjectRoot;
}
setCustomPlansDir(dir: string | undefined): void {
@@ -320,22 +324,10 @@ 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());
const realResolvedPath = resolveToRealPath(resolvedPath);
if (!isSubpath(realProjectRoot, realResolvedPath)) {
throw new Error(
`Custom plans directory '${this.customPlansDir}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`,
);
}
return resolvedPath;
getPlansDir(extensionPlanDir?: string): string {
const customDir = extensionPlanDir || this.customPlansDir;
if (customDir) {
return path.resolve(this.getProjectRoot(), customDir);
}
return this.getProjectTempPlansDir();
}
@@ -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.
+11
View File
@@ -0,0 +1,11 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export class SecurityError extends Error {
constructor(message: string) {
super(message);
this.name = 'SecurityError';
}
}
+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'),
getProjectTempTrackerDir: vi
.fn()
.mockReturnValue('/mock/.gemini/tmp/session/tracker'),
@@ -423,6 +424,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'),
},
+2 -1
View File
@@ -276,7 +276,8 @@ export * from './voice/responseFormatter.js';
// Export types from @google/genai
export type { Content, Part, FunctionCall } from '@google/genai';
// Export context types and profiles
export * from './context/types.js';
export * from './context/profiles.js';
export * from './core/errors.js';
@@ -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
@@ -188,7 +188,7 @@ export class PromptProvider {
() => ({
interactive: interactiveMode,
planModeToolsList,
plansDir: context.config.storage.getPlansDir(),
plansDir: context.config.getPlansDir(),
approvedPlanPath: context.config.getApprovedPlanPath(),
}),
isPlanMode,
+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> {
@@ -237,6 +237,7 @@ describe('ToolRegistry', () => {
beforeEach(() => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
vi.mocked(fs.statSync).mockReturnValue({
isDirectory: () => true,
} as fs.Stats);
+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(),