mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -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 MockedGeminiChat = vi.mocked(GeminiChat);
|
||||||
const mockedGetDirectoryContextString = vi.mocked(getDirectoryContextString);
|
const mockedGetDirectoryContextString = vi.mocked(getDirectoryContextString);
|
||||||
const mockedPromptIdContext = vi.mocked(promptIdContext);
|
const mockedPromptIdContext = vi.mocked(promptIdContext);
|
||||||
@@ -396,6 +417,8 @@ describe('LocalAgentExecutor', () => {
|
|||||||
);
|
);
|
||||||
mockedLogAgentStart.mockReset();
|
mockedLogAgentStart.mockReset();
|
||||||
mockedLogAgentFinish.mockReset();
|
mockedLogAgentFinish.mockReset();
|
||||||
|
mockedRunWithScopedWorkspaceContext.mockClear();
|
||||||
|
mockedCreateScopedWorkspaceContext.mockClear();
|
||||||
mockedPromptIdContext.getStore.mockReset();
|
mockedPromptIdContext.getStore.mockReset();
|
||||||
mockedPromptIdContext.run.mockImplementation((_id, fn) => fn());
|
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)', () => {
|
describe('run (Execution Loop and Logic)', () => {
|
||||||
it('should log AgentFinish with error if run throws', async () => {
|
it('should log AgentFinish with error if run throws', async () => {
|
||||||
const definition = createTestDefinition();
|
const definition = createTestDefinition();
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ import {
|
|||||||
formatBackgroundCompletionForModel,
|
formatBackgroundCompletionForModel,
|
||||||
} from '../utils/fastAckHelper.js';
|
} from '../utils/fastAckHelper.js';
|
||||||
import type { InjectionSource } from '../config/injectionService.js';
|
import type { InjectionSource } from '../config/injectionService.js';
|
||||||
|
import {
|
||||||
|
createScopedWorkspaceContext,
|
||||||
|
runWithScopedWorkspaceContext,
|
||||||
|
} from '../config/scoped-config.js';
|
||||||
import { CompleteTaskTool } from '../tools/complete-task.js';
|
import { CompleteTaskTool } from '../tools/complete-task.js';
|
||||||
import { COMPLETE_TASK_TOOL_NAME } from '../tools/definitions/base-declarations.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.
|
* @returns A promise that resolves to the agent's final output.
|
||||||
*/
|
*/
|
||||||
async run(inputs: AgentInputs, signal: AbortSignal): Promise<OutputObject> {
|
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();
|
const startTime = Date.now();
|
||||||
let turnCounter = 0;
|
let turnCounter = 0;
|
||||||
let terminateReason: AgentTerminateMode = AgentTerminateMode.ERROR;
|
let terminateReason: AgentTerminateMode = AgentTerminateMode.ERROR;
|
||||||
|
|||||||
@@ -150,4 +150,11 @@ describe('MemoryManagerAgent', () => {
|
|||||||
const agent = MemoryManagerAgent(createMockConfig());
|
const agent = MemoryManagerAgent(createMockConfig());
|
||||||
expect(agent.modelConfig.model).toBe('flash');
|
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: {
|
modelConfig: {
|
||||||
model: GEMINI_MODEL_ALIAS_FLASH,
|
model: GEMINI_MODEL_ALIAS_FLASH,
|
||||||
},
|
},
|
||||||
|
workspaceDirectories: [globalGeminiDir],
|
||||||
toolConfig: {
|
toolConfig: {
|
||||||
tools: [
|
tools: [
|
||||||
READ_FILE_TOOL_NAME,
|
READ_FILE_TOOL_NAME,
|
||||||
|
|||||||
@@ -281,21 +281,11 @@ export class AgentRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Register the memory manager agent as a replacement for the save_memory tool.
|
// 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()) {
|
if (this.config.isMemoryManagerEnabled()) {
|
||||||
this.registerLocalAgent(MemoryManagerAgent(this.config));
|
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
|
// Optional configs
|
||||||
toolConfig?: ToolConfig;
|
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.
|
* Optional inline MCP servers for this agent.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ import type { GenerateContentParameters } from '@google/genai';
|
|||||||
export type { MCPOAuthConfig, AnyToolInvocation, AnyDeclarativeTool };
|
export type { MCPOAuthConfig, AnyToolInvocation, AnyDeclarativeTool };
|
||||||
import type { AnyToolInvocation, AnyDeclarativeTool } from '../tools/tools.js';
|
import type { AnyToolInvocation, AnyDeclarativeTool } from '../tools/tools.js';
|
||||||
import { WorkspaceContext } from '../utils/workspaceContext.js';
|
import { WorkspaceContext } from '../utils/workspaceContext.js';
|
||||||
|
import { getWorkspaceContextOverride } from './scoped-config.js';
|
||||||
import { Storage } from './storage.js';
|
import { Storage } from './storage.js';
|
||||||
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
|
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
|
||||||
import { FileExclusions } from '../utils/ignorePatterns.js';
|
import { FileExclusions } from '../utils/ignorePatterns.js';
|
||||||
@@ -2001,7 +2002,7 @@ export class Config implements McpContext, AgentLoopContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getWorkspaceContext(): WorkspaceContext {
|
getWorkspaceContext(): WorkspaceContext {
|
||||||
return this.workspaceContext;
|
return getWorkspaceContextOverride() ?? this.workspaceContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
getAgentRegistry(): AgentRegistry {
|
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
|
# Policy for Memory Manager Agent
|
||||||
# Allows the save_memory agent to manage memories in the ~/.gemini/ folder.
|
# Allows the save_memory agent to manage memories in the ~/.gemini/ folder.
|
||||||
|
|
||||||
|
# Read-only tools: allow access to anything under .gemini/
|
||||||
[[rule]]
|
[[rule]]
|
||||||
subagent = "save_memory"
|
subagent = "save_memory"
|
||||||
toolName = ["read_file", "write_file", "replace", "list_directory", "glob", "grep_search"]
|
toolName = ["read_file", "list_directory", "glob", "grep_search"]
|
||||||
decision = "allow"
|
decision = "allow"
|
||||||
priority = 100
|
priority = 100
|
||||||
argsPattern = "(^|.*/)\\.gemini/.*"
|
argsPattern = "(^|.*/)\\.gemini/.*"
|
||||||
denyMessage = "Memory Manager is only allowed to access the .gemini folder."
|
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