diff --git a/packages/core/src/agents/local-executor.test.ts b/packages/core/src/agents/local-executor.test.ts index 2ecd305a04..32fc93f690 100644 --- a/packages/core/src/agents/local-executor.test.ts +++ b/packages/core/src/agents/local-executor.test.ts @@ -198,6 +198,27 @@ vi.mock('../utils/promptIdContext.js', async (importOriginal) => { }; }); +vi.mock('../config/scoped-config.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + runWithScopedWorkspaceContext: vi.fn(actual.runWithScopedWorkspaceContext), + createScopedWorkspaceContext: vi.fn(actual.createScopedWorkspaceContext), + }; +}); + +import { + runWithScopedWorkspaceContext, + createScopedWorkspaceContext, +} from '../config/scoped-config.js'; +const mockedRunWithScopedWorkspaceContext = vi.mocked( + runWithScopedWorkspaceContext, +); +const mockedCreateScopedWorkspaceContext = vi.mocked( + createScopedWorkspaceContext, +); + const MockedGeminiChat = vi.mocked(GeminiChat); const mockedGetDirectoryContextString = vi.mocked(getDirectoryContextString); const mockedPromptIdContext = vi.mocked(promptIdContext); @@ -396,6 +417,8 @@ describe('LocalAgentExecutor', () => { ); mockedLogAgentStart.mockReset(); mockedLogAgentFinish.mockReset(); + mockedRunWithScopedWorkspaceContext.mockClear(); + mockedCreateScopedWorkspaceContext.mockClear(); mockedPromptIdContext.getStore.mockReset(); mockedPromptIdContext.run.mockImplementation((_id, fn) => fn()); @@ -885,6 +908,55 @@ describe('LocalAgentExecutor', () => { }); }); + describe('run (Workspace Scoping)', () => { + it('should use runWithScopedWorkspaceContext when workspaceDirectories is set', async () => { + const definition = createTestDefinition(); + definition.workspaceDirectories = ['/tmp/extra-dir']; + const executor = await LocalAgentExecutor.create( + definition, + mockConfig, + onActivity, + ); + + // Mock a simple complete_task response so run() terminates + mockModelResponse([ + { + name: COMPLETE_TASK_TOOL_NAME, + args: { finalResult: 'done' }, + id: 'c1', + }, + ]); + + await executor.run({ goal: 'test' }, signal); + + expect(mockedCreateScopedWorkspaceContext).toHaveBeenCalledOnce(); + expect(mockedRunWithScopedWorkspaceContext).toHaveBeenCalledOnce(); + }); + + it('should not use runWithScopedWorkspaceContext when workspaceDirectories is not set', async () => { + const definition = createTestDefinition(); + const executor = await LocalAgentExecutor.create( + definition, + mockConfig, + onActivity, + ); + + // Mock a simple complete_task response so run() terminates + mockModelResponse([ + { + name: COMPLETE_TASK_TOOL_NAME, + args: { finalResult: 'done' }, + id: 'c1', + }, + ]); + + await executor.run({ goal: 'test' }, signal); + + expect(mockedCreateScopedWorkspaceContext).not.toHaveBeenCalled(); + expect(mockedRunWithScopedWorkspaceContext).not.toHaveBeenCalled(); + }); + }); + describe('run (Execution Loop and Logic)', () => { it('should log AgentFinish with error if run throws', async () => { const definition = createTestDefinition(); diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index 83e3ee69b1..50a93ec7ff 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -73,6 +73,10 @@ import { formatBackgroundCompletionForModel, } from '../utils/fastAckHelper.js'; import type { InjectionSource } from '../config/injectionService.js'; +import { + createScopedWorkspaceContext, + runWithScopedWorkspaceContext, +} from '../config/scoped-config.js'; import { CompleteTaskTool } from '../tools/complete-task.js'; import { COMPLETE_TASK_TOOL_NAME } from '../tools/definitions/base-declarations.js'; @@ -521,6 +525,27 @@ export class LocalAgentExecutor { * @returns A promise that resolves to the agent's final output. */ async run(inputs: AgentInputs, signal: AbortSignal): Promise { + // If the agent definition declares additional workspace directories, + // wrap execution in a scoped workspace context. All calls to + // Config.getWorkspaceContext() within this scope will see the extended + // directories, without mutating the shared Config. + const dirs = this.definition.workspaceDirectories; + if (dirs && dirs.length > 0) { + const scopedCtx = createScopedWorkspaceContext( + this.context.config.getWorkspaceContext(), + dirs, + ); + return runWithScopedWorkspaceContext(scopedCtx, () => + this.runInternal(inputs, signal), + ); + } + return this.runInternal(inputs, signal); + } + + private async runInternal( + inputs: AgentInputs, + signal: AbortSignal, + ): Promise { const startTime = Date.now(); let turnCounter = 0; let terminateReason: AgentTerminateMode = AgentTerminateMode.ERROR; diff --git a/packages/core/src/agents/memory-manager-agent.test.ts b/packages/core/src/agents/memory-manager-agent.test.ts index c4f9879e8f..a917a415c4 100644 --- a/packages/core/src/agents/memory-manager-agent.test.ts +++ b/packages/core/src/agents/memory-manager-agent.test.ts @@ -150,4 +150,11 @@ describe('MemoryManagerAgent', () => { const agent = MemoryManagerAgent(createMockConfig()); expect(agent.modelConfig.model).toBe('flash'); }); + + it('should declare workspaceDirectories containing the global .gemini directory', () => { + const agent = MemoryManagerAgent(createMockConfig()); + const globalGeminiDir = Storage.getGlobalGeminiDir(); + expect(agent.workspaceDirectories).toBeDefined(); + expect(agent.workspaceDirectories).toContain(globalGeminiDir); + }); }); diff --git a/packages/core/src/agents/memory-manager-agent.ts b/packages/core/src/agents/memory-manager-agent.ts index 1687da6d1f..95ef382ea3 100644 --- a/packages/core/src/agents/memory-manager-agent.ts +++ b/packages/core/src/agents/memory-manager-agent.ts @@ -131,6 +131,7 @@ reply with what you need, and exit. Do not search the codebase for the missing c modelConfig: { model: GEMINI_MODEL_ALIAS_FLASH, }, + workspaceDirectories: [globalGeminiDir], toolConfig: { tools: [ READ_FILE_TOOL_NAME, diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index 36fe970cdf..7ff547fba9 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -281,21 +281,11 @@ export class AgentRegistry { } // Register the memory manager agent as a replacement for the save_memory tool. + // The agent declares its own workspaceDirectories (e.g. ~/.gemini) which are + // scoped to its execution via runWithScopedWorkspaceContext in LocalAgentExecutor, + // keeping the main agent's workspace context clean. if (this.config.isMemoryManagerEnabled()) { this.registerLocalAgent(MemoryManagerAgent(this.config)); - - // Ensure the global .gemini directory is accessible to tools. - // This allows the save_memory agent to read and write to it. - // Access control is enforced by the Policy Engine (memory-manager.toml). - try { - const globalDir = Storage.getGlobalGeminiDir(); - this.config.getWorkspaceContext().addDirectory(globalDir); - } catch (e) { - debugLogger.warn( - `[AgentRegistry] Could not add global .gemini directory to workspace:`, - e, - ); - } } } diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index a7d921453b..6cf30bcfb4 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -215,6 +215,18 @@ export interface LocalAgentDefinition< // Optional configs toolConfig?: ToolConfig; + /** + * Optional additional workspace directories scoped to this agent. + * When provided, the agent receives a workspace context that extends + * the parent's with these directories. Other agents and the main + * session are unaffected. If omitted, the parent workspace context + * is inherited unchanged. + * + * Note: Filesystem root paths (e.g. `/` or `C:\`) are rejected at + * runtime to prevent accidentally granting access to the entire filesystem. + */ + workspaceDirectories?: string[]; + /** * Optional inline MCP servers for this agent. */ diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index d9ab9e597c..3d967c0fa7 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -132,6 +132,7 @@ import type { GenerateContentParameters } from '@google/genai'; export type { MCPOAuthConfig, AnyToolInvocation, AnyDeclarativeTool }; import type { AnyToolInvocation, AnyDeclarativeTool } from '../tools/tools.js'; import { WorkspaceContext } from '../utils/workspaceContext.js'; +import { getWorkspaceContextOverride } from './scoped-config.js'; import { Storage } from './storage.js'; import type { ShellExecutionConfig } from '../services/shellExecutionService.js'; import { FileExclusions } from '../utils/ignorePatterns.js'; @@ -2001,7 +2002,7 @@ export class Config implements McpContext, AgentLoopContext { } getWorkspaceContext(): WorkspaceContext { - return this.workspaceContext; + return getWorkspaceContextOverride() ?? this.workspaceContext; } getAgentRegistry(): AgentRegistry { diff --git a/packages/core/src/config/scoped-config.test.ts b/packages/core/src/config/scoped-config.test.ts new file mode 100644 index 0000000000..59689e25a9 --- /dev/null +++ b/packages/core/src/config/scoped-config.test.ts @@ -0,0 +1,206 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as os from 'node:os'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + createScopedWorkspaceContext, + runWithScopedWorkspaceContext, + getWorkspaceContextOverride, +} from './scoped-config.js'; +import { Config } from './config.js'; + +vi.mock('../utils/paths.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveToRealPath: vi.fn((p) => p), + isSubpath: (parent: string, child: string) => child.startsWith(parent), + }; +}); + +describe('createScopedWorkspaceContext', () => { + let tempDir: string; + let extraDir: string; + let config: Config; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scoped-config-')); + extraDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scoped-extra-')); + + config = new Config({ + targetDir: tempDir, + sessionId: 'test-session', + debugMode: false, + cwd: tempDir, + model: 'test-model', + }); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(extraDir, { recursive: true, force: true }); + }); + + it('should include parent workspace directories', () => { + const scoped = createScopedWorkspaceContext(config.getWorkspaceContext(), [ + extraDir, + ]); + const dirs = scoped.getDirectories(); + + expect(dirs).toContain(fs.realpathSync(tempDir)); + }); + + it('should include additional directories', () => { + const scoped = createScopedWorkspaceContext(config.getWorkspaceContext(), [ + extraDir, + ]); + const dirs = scoped.getDirectories(); + + expect(dirs).toContain(fs.realpathSync(extraDir)); + }); + + it('should not modify the parent workspace context', () => { + const parentDirsBefore = [...config.getWorkspaceContext().getDirectories()]; + + createScopedWorkspaceContext(config.getWorkspaceContext(), [extraDir]); + + const parentDirsAfter = [...config.getWorkspaceContext().getDirectories()]; + expect(parentDirsAfter).toEqual(parentDirsBefore); + expect(parentDirsAfter).not.toContain(fs.realpathSync(extraDir)); + }); + + it('should throw when parent context has no directories', () => { + const emptyCtx = { getDirectories: () => [] } as unknown as ReturnType< + typeof config.getWorkspaceContext + >; + expect(() => createScopedWorkspaceContext(emptyCtx, [extraDir])).toThrow( + 'parent has no directories', + ); + }); + + it('should return parent context unchanged when additionalDirectories is empty', () => { + const parentCtx = config.getWorkspaceContext(); + const scoped = createScopedWorkspaceContext(parentCtx, []); + expect(scoped).toBe(parentCtx); + }); + + it('should throw when adding a filesystem root directory', () => { + expect(() => + createScopedWorkspaceContext(config.getWorkspaceContext(), ['/']), + ).toThrow('Cannot add filesystem root'); + }); +}); + +describe('runWithScopedWorkspaceContext', () => { + let tempDir: string; + let extraDir: string; + let config: Config; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scoped-run-')); + extraDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scoped-run-extra-')); + + config = new Config({ + targetDir: tempDir, + sessionId: 'test-session', + debugMode: false, + cwd: tempDir, + model: 'test-model', + }); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(extraDir, { recursive: true, force: true }); + }); + + it('should override Config.getWorkspaceContext() within scope', () => { + const scoped = createScopedWorkspaceContext(config.getWorkspaceContext(), [ + extraDir, + ]); + + runWithScopedWorkspaceContext(scoped, () => { + const ctx = config.getWorkspaceContext(); + expect(ctx).toBe(scoped); + expect(ctx.getDirectories()).toContain(fs.realpathSync(extraDir)); + }); + }); + + it('should not affect Config.getWorkspaceContext() outside scope', () => { + const scoped = createScopedWorkspaceContext(config.getWorkspaceContext(), [ + extraDir, + ]); + + runWithScopedWorkspaceContext(scoped, () => { + // Inside scope — overridden + expect(config.getWorkspaceContext()).toBe(scoped); + }); + + // Outside scope — original + const ctx = config.getWorkspaceContext(); + expect(ctx.getDirectories()).not.toContain(fs.realpathSync(extraDir)); + }); + + it('should allow paths within scoped directories via Config.isPathAllowed()', () => { + const scoped = createScopedWorkspaceContext(config.getWorkspaceContext(), [ + extraDir, + ]); + // Use realpathSync because WorkspaceContext resolves symlinks internally + const filePath = path.join(fs.realpathSync(extraDir), 'test.md'); + + // Outside scope — not allowed + expect(config.isPathAllowed(filePath)).toBe(false); + + // Inside scope — allowed + runWithScopedWorkspaceContext(scoped, () => { + expect(config.isPathAllowed(filePath)).toBe(true); + }); + + // After scope — not allowed again + expect(config.isPathAllowed(filePath)).toBe(false); + }); + + it('should still allow parent workspace paths within scope', () => { + const scoped = createScopedWorkspaceContext(config.getWorkspaceContext(), [ + extraDir, + ]); + const filePath = path.join(fs.realpathSync(tempDir), 'src/index.ts'); + + runWithScopedWorkspaceContext(scoped, () => { + expect(config.isPathAllowed(filePath)).toBe(true); + }); + }); + + it('should work with async functions', async () => { + const scoped = createScopedWorkspaceContext(config.getWorkspaceContext(), [ + extraDir, + ]); + + await runWithScopedWorkspaceContext(scoped, async () => { + // Simulate async work + await new Promise((resolve) => setTimeout(resolve, 1)); + const ctx = config.getWorkspaceContext(); + expect(ctx).toBe(scoped); + }); + }); + + it('should return undefined from getWorkspaceContextOverride outside scope', () => { + expect(getWorkspaceContextOverride()).toBeUndefined(); + }); + + it('should return scoped context from getWorkspaceContextOverride inside scope', () => { + const scoped = createScopedWorkspaceContext(config.getWorkspaceContext(), [ + extraDir, + ]); + + runWithScopedWorkspaceContext(scoped, () => { + expect(getWorkspaceContextOverride()).toBe(scoped); + }); + }); +}); diff --git a/packages/core/src/config/scoped-config.ts b/packages/core/src/config/scoped-config.ts new file mode 100644 index 0000000000..90cdea2da6 --- /dev/null +++ b/packages/core/src/config/scoped-config.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; +import * as path from 'node:path'; +import { WorkspaceContext } from '../utils/workspaceContext.js'; + +/** + * AsyncLocalStorage for scoped workspace context overrides. + * + * When a subagent declares additional workspace directories, its execution + * runs inside this store. `Config.getWorkspaceContext()` checks this store + * first, allowing per-agent workspace scoping without mutating the shared + * Config instance. + * + * This follows the same pattern as `toolCallContext` and `promptIdContext`. + */ +const workspaceContextOverride = new AsyncLocalStorage(); + +/** + * Returns the current workspace context override, if any. + * Called by `Config.getWorkspaceContext()` to check for per-agent scoping. + */ +export function getWorkspaceContextOverride(): WorkspaceContext | undefined { + return workspaceContextOverride.getStore(); +} + +/** + * Runs a function with a scoped workspace context override. + * Any calls to `Config.getWorkspaceContext()` within `fn` will return + * the scoped context instead of the default. + * + * @param scopedContext The workspace context to use within the scope. + * @param fn The function to run. + * @returns The result of the function. + */ +export function runWithScopedWorkspaceContext( + scopedContext: WorkspaceContext, + fn: () => T, +): T { + return workspaceContextOverride.run(scopedContext, fn); +} + +/** + * Creates a {@link WorkspaceContext} that extends a parent's directories + * with additional ones. + * + * @param parentContext The parent workspace context. + * @param additionalDirectories Extra directories to include. + * @returns A new WorkspaceContext with the combined directories. + */ +export function createScopedWorkspaceContext( + parentContext: WorkspaceContext, + additionalDirectories: string[], +): WorkspaceContext { + if (additionalDirectories.length === 0) { + return parentContext; + } + + const parentDirs = [...parentContext.getDirectories()]; + if (parentDirs.length === 0) { + throw new Error( + 'Cannot create scoped workspace context: parent has no directories', + ); + } + + // Reject overly broad directories (filesystem roots) to prevent + // accidentally granting access to the entire filesystem. + for (const dir of additionalDirectories) { + if (path.resolve(dir) === path.parse(path.resolve(dir)).root) { + throw new Error( + `Cannot add filesystem root "${dir}" as a workspace directory`, + ); + } + } + + // WorkspaceContext's first constructor argument is the primary targetDir. + // getDirectories() returns targetDir first, so parentDirs[0] is always it. + return new WorkspaceContext(parentDirs[0], [ + ...parentDirs.slice(1), + ...additionalDirectories, + ]); +} diff --git a/packages/core/src/policy/policies/memory-manager.toml b/packages/core/src/policy/policies/memory-manager.toml index b1b1b4ddd9..3794871be3 100644 --- a/packages/core/src/policy/policies/memory-manager.toml +++ b/packages/core/src/policy/policies/memory-manager.toml @@ -1,10 +1,20 @@ # Policy for Memory Manager Agent # Allows the save_memory agent to manage memories in the ~/.gemini/ folder. +# Read-only tools: allow access to anything under .gemini/ [[rule]] subagent = "save_memory" -toolName = ["read_file", "write_file", "replace", "list_directory", "glob", "grep_search"] +toolName = ["read_file", "list_directory", "glob", "grep_search"] decision = "allow" priority = 100 argsPattern = "(^|.*/)\\.gemini/.*" denyMessage = "Memory Manager is only allowed to access the .gemini folder." + +# Write tools: only allow .md files under .gemini/ +[[rule]] +subagent = "save_memory" +toolName = ["write_file", "replace"] +decision = "allow" +priority = 100 +argsPattern = "(^|.*/)\\.gemini/.*\\.md\"" +denyMessage = "Memory Manager is only allowed to write .md files in the .gemini folder."