mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 18:14:29 -07:00
feat(core): scope subagent workspace directories via AsyncLocalStorage (#24445)
This commit is contained in:
@@ -198,6 +198,27 @@ vi.mock('../utils/promptIdContext.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../config/scoped-config.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../config/scoped-config.js')>();
|
||||
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();
|
||||
|
||||
@@ -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<TOutput extends z.ZodTypeAny> {
|
||||
* @returns A promise that resolves to the agent's final output.
|
||||
*/
|
||||
async run(inputs: AgentInputs, signal: AbortSignal): Promise<OutputObject> {
|
||||
// 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<OutputObject> {
|
||||
const startTime = Date.now();
|
||||
let turnCounter = 0;
|
||||
let terminateReason: AgentTerminateMode = AgentTerminateMode.ERROR;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<typeof import('../utils/paths.js')>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<WorkspaceContext>();
|
||||
|
||||
/**
|
||||
* 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<T>(
|
||||
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,
|
||||
]);
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user