feat(core): scope subagent workspace directories via AsyncLocalStorage (#24445)

This commit is contained in:
Sandy Tao
2026-04-02 09:33:08 -07:00
committed by GitHub
parent e0044f2868
commit 63cc363606
10 changed files with 425 additions and 15 deletions
@@ -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,
+3 -13
View File
@@ -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,
);
}
}
}
+12
View File
@@ -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.
*/
+2 -1
View File
@@ -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);
});
});
});
+86
View File
@@ -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."