mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 16:20:57 -07:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17b06f7f69 | |||
| fb918380d0 | |||
| 4ef38c966c | |||
| 56a3ef94e4 | |||
| e74bfa1040 | |||
| 7554679d15 | |||
| 03c6714c6d |
@@ -264,6 +264,8 @@ it yourself; just report it.
|
||||
| `temperature` | number | No | Model temperature (0.0 - 2.0). |
|
||||
| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `15`. |
|
||||
| `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `5`. |
|
||||
| `policy` | object | No | Scoped policy settings for the agent (see [Policy Engine](../reference/policy-engine.md)). |
|
||||
| `mcp_servers` | object | No | MCP servers private to this agent. Key is server name, value is server configuration. |
|
||||
|
||||
### Optimizing your subagent
|
||||
|
||||
|
||||
@@ -733,37 +733,47 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
}
|
||||
}
|
||||
|
||||
let publicMcpServers: Record<string, MCPServerConfig> | undefined;
|
||||
let privateMcpServers: Record<string, MCPServerConfig> | undefined;
|
||||
|
||||
if (config.mcpServers) {
|
||||
if (this.settings.admin.mcp.enabled === false) {
|
||||
config.mcpServers = undefined;
|
||||
} else {
|
||||
if (this.settings.admin.mcp.enabled !== false) {
|
||||
// Apply admin allowlist if configured
|
||||
const adminAllowlist = this.settings.admin.mcp.config;
|
||||
if (adminAllowlist && Object.keys(adminAllowlist).length > 0) {
|
||||
const result = applyAdminAllowlist(
|
||||
config.mcpServers,
|
||||
adminAllowlist,
|
||||
);
|
||||
config.mcpServers = result.mcpServers;
|
||||
const filteredMcpServers =
|
||||
adminAllowlist && Object.keys(adminAllowlist).length > 0
|
||||
? (() => {
|
||||
const result = applyAdminAllowlist(
|
||||
config.mcpServers,
|
||||
adminAllowlist,
|
||||
);
|
||||
if (result.blockedServerNames.length > 0) {
|
||||
const message = getAdminBlockedMcpServersMessage(
|
||||
result.blockedServerNames,
|
||||
undefined,
|
||||
);
|
||||
coreEvents.emitConsoleLog('warn', message);
|
||||
}
|
||||
return result.mcpServers ?? {};
|
||||
})()
|
||||
: config.mcpServers;
|
||||
|
||||
if (result.blockedServerNames.length > 0) {
|
||||
const message = getAdminBlockedMcpServersMessage(
|
||||
result.blockedServerNames,
|
||||
undefined,
|
||||
);
|
||||
coreEvents.emitConsoleLog('warn', message);
|
||||
}
|
||||
}
|
||||
const entries = Object.entries(filteredMcpServers).map(
|
||||
([key, value]) =>
|
||||
[key, filterMcpConfig(value)] as [string, MCPServerConfig],
|
||||
);
|
||||
|
||||
// Then apply local filtering/sanitization
|
||||
if (config.mcpServers) {
|
||||
config.mcpServers = Object.fromEntries(
|
||||
Object.entries(config.mcpServers).map(([key, value]) => [
|
||||
key,
|
||||
filterMcpConfig(value),
|
||||
]),
|
||||
);
|
||||
}
|
||||
publicMcpServers = Object.fromEntries(
|
||||
entries.filter(([, v]) => v.visibility !== 'private'),
|
||||
);
|
||||
privateMcpServers = Object.fromEntries(
|
||||
entries.filter(([, v]) => v.visibility === 'private'),
|
||||
);
|
||||
|
||||
if (Object.keys(publicMcpServers).length === 0)
|
||||
publicMcpServers = undefined;
|
||||
if (Object.keys(privateMcpServers).length === 0)
|
||||
privateMcpServers = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -854,9 +864,16 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
const agentLoadResult = await loadAgentsFromDirectory(
|
||||
path.join(effectiveExtensionPath, 'agents'),
|
||||
);
|
||||
agentLoadResult.agents = agentLoadResult.agents.map((agent) =>
|
||||
recursivelyHydrateStrings(agent, hydrationContext),
|
||||
);
|
||||
agentLoadResult.agents = agentLoadResult.agents.map((agent) => {
|
||||
const hydrated = recursivelyHydrateStrings(agent, hydrationContext);
|
||||
if (privateMcpServers && hydrated.kind === 'local') {
|
||||
hydrated.mcpServers = {
|
||||
...privateMcpServers,
|
||||
...hydrated.mcpServers,
|
||||
};
|
||||
}
|
||||
return hydrated;
|
||||
});
|
||||
|
||||
// Log errors but don't fail the entire extension load
|
||||
for (const error of agentLoadResult.errors) {
|
||||
@@ -871,7 +888,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
path: effectiveExtensionPath,
|
||||
contextFiles,
|
||||
installMetadata,
|
||||
mcpServers: config.mcpServers,
|
||||
mcpServers: publicMcpServers,
|
||||
excludeTools: config.excludeTools,
|
||||
hooks,
|
||||
isActive: this.extensionEnablementManager.isEnabled(
|
||||
|
||||
@@ -16,6 +16,12 @@ import {
|
||||
DEFAULT_MAX_TIME_MINUTES,
|
||||
} from './types.js';
|
||||
import type { A2AAuthConfig } from './auth-provider/types.js';
|
||||
import { type MCPServerConfig } from '../config/config.js';
|
||||
import { type PolicySettings } from '../policy/types.js';
|
||||
import {
|
||||
PolicySettingsSchema,
|
||||
MCPServersConfigSchema,
|
||||
} from '../policy/schemas.js';
|
||||
import { isValidToolName } from '../tools/tool-names.js';
|
||||
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
@@ -38,6 +44,8 @@ interface FrontmatterLocalAgentDefinition
|
||||
temperature?: number;
|
||||
max_turns?: number;
|
||||
timeout_mins?: number;
|
||||
policy?: PolicySettings;
|
||||
mcp_servers?: Record<string, MCPServerConfig>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +119,8 @@ const localAgentSchema = z
|
||||
temperature: z.number().optional(),
|
||||
max_turns: z.number().int().positive().optional(),
|
||||
timeout_mins: z.number().int().positive().optional(),
|
||||
policy: PolicySettingsSchema.optional(),
|
||||
mcp_servers: MCPServersConfigSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -470,6 +480,8 @@ export function markdownToAgentDefinition(
|
||||
tools: markdown.tools,
|
||||
}
|
||||
: undefined,
|
||||
policy: markdown.policy,
|
||||
mcpServers: markdown.mcp_servers,
|
||||
inputConfig,
|
||||
metadata,
|
||||
};
|
||||
|
||||
@@ -554,6 +554,104 @@ describe('LocalAgentExecutor', () => {
|
||||
|
||||
getToolSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should support scoped policy and unique toolsets', async () => {
|
||||
const toolToAllow = 'ls';
|
||||
const toolToBlock = READ_FILE_TOOL_NAME;
|
||||
|
||||
const definition = createTestDefinition([toolToAllow, toolToBlock]);
|
||||
definition.policy = {
|
||||
tools: {
|
||||
exclude: [toolToBlock],
|
||||
},
|
||||
};
|
||||
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
mockConfig,
|
||||
onActivity,
|
||||
);
|
||||
|
||||
const agentRegistry = executor['toolRegistry'];
|
||||
|
||||
// Tool explicitly allowed in definition but BLOCKED by policy should NOT be active
|
||||
expect(agentRegistry.getTool(toolToAllow)).toBeDefined();
|
||||
expect(agentRegistry.getTool(toolToBlock)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should incorporate ADMIN policies from parent context into scoped policy engine', async () => {
|
||||
const adminToolToBlock = 'admin_blocked_tool';
|
||||
const userToolToBlock = 'user_blocked_tool';
|
||||
|
||||
// Mock parent policy engine with admin and user rules
|
||||
const parentPolicyEngine = mockConfig.getPolicyEngine();
|
||||
vi.spyOn(parentPolicyEngine, 'getRules').mockReturnValue([
|
||||
{
|
||||
toolName: adminToolToBlock,
|
||||
decision: ApprovalMode.DEFAULT, // Use actual enum value if possible, or cast appropriately
|
||||
priority: 5.1, // Admin tier
|
||||
source: 'Admin Policy',
|
||||
},
|
||||
{
|
||||
toolName: userToolToBlock,
|
||||
decision: ApprovalMode.DEFAULT,
|
||||
priority: 4.1, // User tier
|
||||
source: 'User Policy',
|
||||
},
|
||||
] as unknown as PolicyRule[]);
|
||||
vi.spyOn(parentPolicyEngine, 'getCheckers').mockReturnValue([]);
|
||||
|
||||
// Create a subagent definition that has its own policy
|
||||
const definition = createTestDefinition([
|
||||
adminToolToBlock,
|
||||
userToolToBlock,
|
||||
LS_TOOL_NAME,
|
||||
]);
|
||||
definition.policy = {
|
||||
tools: {
|
||||
allowed: [userToolToBlock], // Subagent tries to allow what user blocked
|
||||
},
|
||||
};
|
||||
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
mockConfig,
|
||||
onActivity,
|
||||
);
|
||||
|
||||
const scopedPolicyEngine = executor['runtimeContext'].getPolicyEngine();
|
||||
const rules = scopedPolicyEngine.getRules();
|
||||
|
||||
// Should have incorporated the ADMIN rule
|
||||
expect(rules).toContainEqual(
|
||||
expect.objectContaining({
|
||||
toolName: adminToolToBlock,
|
||||
priority: 5.1,
|
||||
}),
|
||||
);
|
||||
|
||||
// Should NOT have incorporated the USER rule
|
||||
expect(rules).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
toolName: userToolToBlock,
|
||||
priority: 4.1,
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify effective decisions
|
||||
const adminCheck = await scopedPolicyEngine.check(
|
||||
{ name: adminToolToBlock, args: {} },
|
||||
undefined,
|
||||
);
|
||||
expect(adminCheck.decision).toBe('deny');
|
||||
|
||||
const userCheck = await scopedPolicyEngine.check(
|
||||
{ name: userToolToBlock, args: {} },
|
||||
undefined,
|
||||
);
|
||||
// User block was NOT inherited, and subagent policy allowed it.
|
||||
expect(userCheck.decision).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('run (Execution Loop and Logic)', () => {
|
||||
@@ -2458,4 +2556,59 @@ describe('LocalAgentExecutor', () => {
|
||||
expect(mockSetHistory).toHaveBeenCalledWith(compressedHistory);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Isolated Tool Discovery and Shadowing', () => {
|
||||
it('should allow subagent-specific MCP tools to shadow global tools in the isolated registry', async () => {
|
||||
const toolName = 'shadowed_tool';
|
||||
const globalTool = new MockTool({ name: toolName, description: 'Global Version' });
|
||||
parentToolRegistry.registerTool(globalTool);
|
||||
|
||||
const subagentMcpTool = {
|
||||
tool: vi.fn(),
|
||||
callTool: vi.fn(),
|
||||
} as unknown as CallableTool;
|
||||
|
||||
const mcpTool = new DiscoveredMCPTool(
|
||||
subagentMcpTool,
|
||||
'private-server',
|
||||
toolName,
|
||||
'Subagent Version',
|
||||
{},
|
||||
mockConfig.getMessageBus(),
|
||||
);
|
||||
|
||||
// Mock McpClientManager to register our shadowing tool
|
||||
const definition = createTestDefinition();
|
||||
definition.mcpServers = {
|
||||
'private-server': { command: 'node', args: ['server.js'] }
|
||||
};
|
||||
|
||||
// We need to mock the McpClientManager's behavior
|
||||
const McpClientManagerModule = await import('../tools/mcp-client-manager.js');
|
||||
vi.spyOn(McpClientManagerModule.McpClientManager.prototype, 'maybeDiscoverMcpServer')
|
||||
.mockImplementation(async function(this: unknown) {
|
||||
// Manually register the tool into the registry provided to the manager
|
||||
const manager = this as unknown as { toolRegistry: ToolRegistry };
|
||||
manager.toolRegistry.registerTool(mcpTool);
|
||||
});
|
||||
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
mockConfig,
|
||||
onActivity,
|
||||
);
|
||||
|
||||
const agentRegistry = executor['toolRegistry'];
|
||||
|
||||
// The tool in the agent's registry should be the subagent's version
|
||||
const tool = agentRegistry.getTool(toolName);
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool?.description).toContain('Subagent Version');
|
||||
expect(tool).not.toBe(globalTool);
|
||||
|
||||
// The global registry should remain unchanged
|
||||
expect(parentToolRegistry.getTool(toolName)).toBe(globalTool);
|
||||
expect(parentToolRegistry.getTool(toolName)?.description).toContain('Global Version');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,13 @@ import type {
|
||||
Schema,
|
||||
} from '@google/genai';
|
||||
import { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
|
||||
import {
|
||||
DiscoveredMCPTool,
|
||||
MCP_QUALIFIED_NAME_SEPARATOR,
|
||||
} from '../tools/mcp-tool.js';
|
||||
import { PolicyEngine } from '../policy/policy-engine.js';
|
||||
import { createPolicyEngineConfig } from '../policy/config.js';
|
||||
import { McpClientManager } from '../tools/mcp-client-manager.js';
|
||||
import { CompressionStatus } from '../core/turn.js';
|
||||
import { type ToolCallRequestInfo } from '../scheduler/types.js';
|
||||
import { ChatCompressionService } from '../services/chatCompressionService.js';
|
||||
@@ -115,12 +121,81 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
runtimeContext: Config,
|
||||
onActivity?: ActivityCallback,
|
||||
): Promise<LocalAgentExecutor<TOutput>> {
|
||||
const parentToolRegistry = runtimeContext.getToolRegistry();
|
||||
|
||||
// Create an isolated tool registry for this agent instance.
|
||||
const agentToolRegistry = new ToolRegistry(
|
||||
runtimeContext,
|
||||
runtimeContext.getMessageBus(),
|
||||
);
|
||||
const parentToolRegistry = runtimeContext.getToolRegistry();
|
||||
|
||||
// Create a scoped configuration if subagent has private policies or MCP servers.
|
||||
let agentContext = runtimeContext;
|
||||
|
||||
if (definition.policy || definition.mcpServers) {
|
||||
const parentPolicyEngine = runtimeContext.getPolicyEngine();
|
||||
// Admin policies have priority >= ADMIN_POLICY_TIER (5).
|
||||
const adminRules = parentPolicyEngine
|
||||
.getRules()
|
||||
.filter((r) => (r.priority ?? 0) >= 5);
|
||||
const adminCheckers = parentPolicyEngine
|
||||
.getCheckers()
|
||||
.filter((c) => (c.priority ?? 0) >= 5);
|
||||
|
||||
const policyConfig = definition.policy
|
||||
? await createPolicyEngineConfig(
|
||||
definition.policy,
|
||||
runtimeContext.getApprovalMode(),
|
||||
undefined, // defaultPoliciesDir
|
||||
adminRules,
|
||||
adminCheckers,
|
||||
)
|
||||
: {
|
||||
rules: adminRules,
|
||||
checkers: adminCheckers,
|
||||
approvalMode: runtimeContext.getApprovalMode(),
|
||||
};
|
||||
|
||||
const scopedPolicyEngine = new PolicyEngine(
|
||||
policyConfig,
|
||||
runtimeContext.getCheckerRunner(),
|
||||
);
|
||||
|
||||
// Populate scoped registry with parent's tools (including built-ins).
|
||||
// If a subagent has private MCP servers, tools from those servers will
|
||||
// be registered during discovery and will overwrite any global tools
|
||||
// with the same name in this isolated registry.
|
||||
for (const tool of parentToolRegistry.getAllKnownTools()) {
|
||||
agentToolRegistry.registerTool(tool);
|
||||
}
|
||||
|
||||
let scopedMcpManager: McpClientManager | undefined;
|
||||
if (definition.mcpServers) {
|
||||
scopedMcpManager = new McpClientManager(
|
||||
runtimeContext.getClientVersion(),
|
||||
agentToolRegistry,
|
||||
runtimeContext, // Use parent context for some services, but we will scope it soon
|
||||
);
|
||||
}
|
||||
|
||||
agentContext = runtimeContext.createScopedConfig({
|
||||
policyEngine: scopedPolicyEngine,
|
||||
toolRegistry: agentToolRegistry,
|
||||
mcpClientManager: scopedMcpManager,
|
||||
});
|
||||
|
||||
// Update registry and mcp manager to use the scoped context
|
||||
agentToolRegistry.setConfig(agentContext);
|
||||
if (scopedMcpManager) {
|
||||
scopedMcpManager.setConfig(agentContext);
|
||||
|
||||
// Discover and register subagent-specific MCP tools
|
||||
for (const [name, config] of Object.entries(definition.mcpServers!)) {
|
||||
await scopedMcpManager.maybeDiscoverMcpServer(name, config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allAgentNames = new Set(
|
||||
runtimeContext.getAgentRegistry().getAllAgentNames(),
|
||||
);
|
||||
@@ -182,7 +257,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
|
||||
return new LocalAgentExecutor(
|
||||
definition,
|
||||
runtimeContext,
|
||||
agentContext,
|
||||
agentToolRegistry,
|
||||
parentPromptId,
|
||||
parentCallId,
|
||||
|
||||
@@ -14,6 +14,8 @@ import { type z } from 'zod';
|
||||
import type { ModelConfig } from '../services/modelConfigService.js';
|
||||
import type { AnySchema } from 'ajv';
|
||||
import type { A2AAuthConfig } from './auth-provider/types.js';
|
||||
import type { PolicySettings } from '../policy/types.js';
|
||||
import type { MCPServerConfig } from '../config/config.js';
|
||||
|
||||
/**
|
||||
* Describes the possible termination modes for an agent.
|
||||
@@ -130,6 +132,16 @@ export interface LocalAgentDefinition<
|
||||
// Optional configs
|
||||
toolConfig?: ToolConfig;
|
||||
|
||||
/**
|
||||
* Scoped policy settings for this agent.
|
||||
*/
|
||||
policy?: PolicySettings;
|
||||
|
||||
/**
|
||||
* MCP servers private to this agent.
|
||||
*/
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
|
||||
/**
|
||||
* An optional function to process the raw output from the agent's final tool
|
||||
* call into a string format.
|
||||
|
||||
@@ -424,6 +424,12 @@ export class MCPServerConfig {
|
||||
readonly targetAudience?: string,
|
||||
/* targetServiceAccount format: <service-account-name>@<project-num>.iam.gserviceaccount.com */
|
||||
readonly targetServiceAccount?: string,
|
||||
/**
|
||||
* Visibility of the MCP server.
|
||||
* 'public' (default): available to all agents.
|
||||
* 'private': hidden from the primary agent, available only to specific subagents.
|
||||
*/
|
||||
readonly visibility?: 'public' | 'private',
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -588,7 +594,9 @@ export interface ConfigParameters {
|
||||
|
||||
export class Config implements McpContext {
|
||||
private toolRegistry!: ToolRegistry;
|
||||
private _scopedToolRegistry?: ToolRegistry;
|
||||
private mcpClientManager?: McpClientManager;
|
||||
private _scopedMcpClientManager?: McpClientManager;
|
||||
private allowedMcpServers: string[];
|
||||
private blockedMcpServers: string[];
|
||||
private allowedEnvironmentVariables: string[];
|
||||
@@ -728,6 +736,8 @@ export class Config implements McpContext {
|
||||
private readonly useWriteTodos: boolean;
|
||||
private readonly messageBus: MessageBus;
|
||||
private readonly policyEngine: PolicyEngine;
|
||||
private readonly checkerRunner: CheckerRunner;
|
||||
private _scopedPolicyEngine?: PolicyEngine;
|
||||
private policyUpdateConfirmationRequest:
|
||||
| PolicyUpdateConfirmationRequest
|
||||
| undefined;
|
||||
@@ -964,6 +974,7 @@ export class Config implements McpContext {
|
||||
checkersPath,
|
||||
timeout: 30000, // 30 seconds to allow for LLM-based checkers
|
||||
});
|
||||
this.checkerRunner = checkerRunner;
|
||||
this.policyUpdateConfirmationRequest =
|
||||
params.policyUpdateConfirmationRequest;
|
||||
|
||||
@@ -1325,10 +1336,18 @@ export class Config implements McpContext {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
getClientVersion(): string {
|
||||
return this.clientVersion;
|
||||
}
|
||||
|
||||
setSessionId(sessionId: string): void {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
getCheckerRunner(): CheckerRunner | undefined {
|
||||
return this.checkerRunner;
|
||||
}
|
||||
|
||||
setTerminalBackground(terminalBackground: string | undefined): void {
|
||||
this.terminalBackground = terminalBackground;
|
||||
}
|
||||
@@ -1578,7 +1597,15 @@ export class Config implements McpContext {
|
||||
}
|
||||
|
||||
getToolRegistry(): ToolRegistry {
|
||||
return this.toolRegistry;
|
||||
return this._scopedToolRegistry ?? this.toolRegistry;
|
||||
}
|
||||
|
||||
getMcpClientManager(): McpClientManager | undefined {
|
||||
return this._scopedMcpClientManager ?? this.mcpClientManager;
|
||||
}
|
||||
|
||||
setMcpClientManager(manager: McpClientManager): void {
|
||||
this.mcpClientManager = manager;
|
||||
}
|
||||
|
||||
getPromptRegistry(): PromptRegistry {
|
||||
@@ -1734,7 +1761,7 @@ export class Config implements McpContext {
|
||||
}
|
||||
}
|
||||
|
||||
const policyExclusions = this.policyEngine.getExcludedTools(
|
||||
const policyExclusions = this.getPolicyEngine().getExcludedTools(
|
||||
toolMetadata,
|
||||
allToolNames,
|
||||
);
|
||||
@@ -1778,9 +1805,6 @@ export class Config implements McpContext {
|
||||
return this.extensionsEnabled;
|
||||
}
|
||||
|
||||
getMcpClientManager(): McpClientManager | undefined {
|
||||
return this.mcpClientManager;
|
||||
}
|
||||
|
||||
setUserInteractedWithMcp(): void {
|
||||
this.mcpClientManager?.setUserInteractedWithMcp();
|
||||
@@ -2669,7 +2693,7 @@ export class Config implements McpContext {
|
||||
}
|
||||
|
||||
getPolicyEngine(): PolicyEngine {
|
||||
return this.policyEngine;
|
||||
return this._scopedPolicyEngine ?? this.policyEngine;
|
||||
}
|
||||
|
||||
getEnableHooks(): boolean {
|
||||
@@ -2960,6 +2984,41 @@ export class Config implements McpContext {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a scoped copy of this configuration with overrides for policy and tools.
|
||||
* Scoped configs are used by subagents to maintain isolation from the primary agent.
|
||||
* This uses prototype-based shadowing for overrides while maintaining access to global state.
|
||||
*/
|
||||
createScopedConfig(overrides: {
|
||||
policyEngine?: PolicyEngine;
|
||||
toolRegistry?: ToolRegistry;
|
||||
mcpClientManager?: McpClientManager;
|
||||
}): Config {
|
||||
const scoped = Object.create(this) as unknown as Config;
|
||||
// Define properties explicitly to ensure they shadow the base class properties
|
||||
Object.defineProperties(scoped, {
|
||||
_scopedPolicyEngine: {
|
||||
value: overrides.policyEngine,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
},
|
||||
_scopedToolRegistry: {
|
||||
value: overrides.toolRegistry,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
},
|
||||
_scopedMcpClientManager: {
|
||||
value: overrides.mcpClientManager,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
},
|
||||
});
|
||||
return scoped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes of resources and removes event listeners.
|
||||
*/
|
||||
|
||||
@@ -250,6 +250,8 @@ export async function createPolicyEngineConfig(
|
||||
settings: PolicySettings,
|
||||
approvalMode: ApprovalMode,
|
||||
defaultPoliciesDir?: string,
|
||||
baseRules: PolicyRule[] = [],
|
||||
baseCheckers: SafetyCheckerRule[] = [],
|
||||
): Promise<PolicyEngineConfig> {
|
||||
const policyDirs = getPolicyDirectories(
|
||||
defaultPoliciesDir,
|
||||
@@ -301,8 +303,8 @@ export async function createPolicyEngineConfig(
|
||||
}
|
||||
}
|
||||
|
||||
const rules: PolicyRule[] = [...tomlRules];
|
||||
const checkers = [...tomlCheckers];
|
||||
const rules: PolicyRule[] = [...baseRules, ...tomlRules];
|
||||
const checkers = [...baseCheckers, ...tomlCheckers];
|
||||
|
||||
// Priority system for policy rules:
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Zod schema for MCPServerConfig.
|
||||
*/
|
||||
export const MCPServerConfigSchema = z.object({
|
||||
command: z.string().optional(),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: z.record(z.string()).optional(),
|
||||
trust: z.boolean().optional(),
|
||||
alwaysAllowTools: z.array(z.string()).optional(),
|
||||
includeTools: z.array(z.string()).optional(),
|
||||
excludeTools: z.array(z.string()).optional(),
|
||||
targetAudience: z.string().optional(),
|
||||
targetServiceAccount: z.string().optional(),
|
||||
visibility: z.enum(['public', 'private']).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Zod schema for PolicySettings.
|
||||
*/
|
||||
export const PolicySettingsSchema = z.preprocess(
|
||||
(val) => {
|
||||
if (typeof val === 'object' && val !== null) {
|
||||
const v = val as Record<string, unknown>;
|
||||
// Map snake_case to camelCase
|
||||
if (v.policy_paths && !v.policyPaths) {
|
||||
v.policyPaths = v.policy_paths;
|
||||
}
|
||||
if (v.workspace_policies_dir && !v.workspacePoliciesDir) {
|
||||
v.workspacePoliciesDir = v.workspace_policies_dir;
|
||||
}
|
||||
if (v.mcp_servers && !v.mcpServers) {
|
||||
v.mcpServers = v.mcp_servers;
|
||||
}
|
||||
}
|
||||
return val;
|
||||
},
|
||||
z.object({
|
||||
mcp: z
|
||||
.object({
|
||||
excluded: z.array(z.string()).optional(),
|
||||
allowed: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
tools: z
|
||||
.object({
|
||||
exclude: z.array(z.string()).optional(),
|
||||
allowed: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
mcpServers: z
|
||||
.record(z.object({ trust: z.boolean().optional() }))
|
||||
.optional(),
|
||||
policyPaths: z.array(z.string()).optional(),
|
||||
workspacePoliciesDir: z.string().optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const MCPServersConfigSchema = z.record(MCPServerConfigSchema);
|
||||
@@ -76,6 +76,14 @@ export class McpClientManager {
|
||||
this.eventEmitter = eventEmitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the configuration used by the MCP client manager.
|
||||
* This is used when creating scoped managers for subagents.
|
||||
*/
|
||||
setConfig(config: Config): void {
|
||||
this.cliConfig = config;
|
||||
}
|
||||
|
||||
setUserInteractedWithMcp() {
|
||||
this.userInteractedWithMcp = true;
|
||||
}
|
||||
|
||||
@@ -206,6 +206,14 @@ export class ToolRegistry {
|
||||
this.messageBus = messageBus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the configuration used by the tool registry.
|
||||
* This is used when creating scoped registries for subagents.
|
||||
*/
|
||||
setConfig(config: Config): void {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
getMessageBus(): MessageBus {
|
||||
return this.messageBus;
|
||||
}
|
||||
@@ -232,6 +240,13 @@ export class ToolRegistry {
|
||||
this.allKnownTools.set(tool.name, tool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all known tools, including inactive ones.
|
||||
*/
|
||||
getAllKnownTools(): AnyDeclarativeTool[] {
|
||||
return Array.from(this.allKnownTools.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters a tool definition by name.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Technical Specification: Subagent Policy Isolation and Unique Toolsets
|
||||
|
||||
## Overview
|
||||
This specification details the implementation of independent policies and unique, isolated toolsets for subagents. Currently, subagents inherit the primary agent's policy and toolset, which limits isolation and security. The proposed mechanism allows subagents to have private tools (built-in and MCP) that are hidden from the primary agent and subject to their own scoped policies.
|
||||
|
||||
## 1. YAML Frontmatter Schema
|
||||
The subagent Markdown configuration (`.md` files) will be updated to support a `policy` block and an `mcp_servers` block in the YAML frontmatter.
|
||||
|
||||
### Schema Definition
|
||||
```yaml
|
||||
---
|
||||
name: security-specialist
|
||||
display_name: Security Specialist
|
||||
description: Expert in vulnerability scanning and security audits.
|
||||
|
||||
# Explicitly listed tools available to the subagent
|
||||
tools:
|
||||
- builtin:read_file
|
||||
- mcp:security-scanner:scan_vulnerabilities
|
||||
|
||||
# Scoped policy for the subagent
|
||||
policy:
|
||||
tools:
|
||||
allowed:
|
||||
- "builtin:read_file"
|
||||
- "mcp:security-scanner:*"
|
||||
exclude:
|
||||
- "builtin:shell"
|
||||
- "builtin:write_file"
|
||||
mcp:
|
||||
allowed:
|
||||
- "security-scanner"
|
||||
excluded:
|
||||
- "*"
|
||||
# Trust configuration for MCP servers
|
||||
mcp_servers:
|
||||
security-scanner:
|
||||
trust: true
|
||||
|
||||
# MCP servers private to this subagent
|
||||
mcp_servers:
|
||||
security-scanner:
|
||||
command: "npx"
|
||||
args: ["@security/mcp-server"]
|
||||
env:
|
||||
API_KEY: "${SECURITY_API_KEY}"
|
||||
---
|
||||
```
|
||||
|
||||
## 2. Orchestration and Scoping Logic
|
||||
The isolation is enforced by creating a scoped execution context for each subagent.
|
||||
|
||||
### Scoped Config and Policy Engine
|
||||
1. **`LocalAgentExecutor.create`**: When a subagent is instantiated, it will no longer share the global `Config` directly.
|
||||
2. **Config Scoping**: A new method `Config.createScopedConfig()` will be implemented to create a lightweight fork of the configuration.
|
||||
3. **Policy Isolation**: If the subagent definition includes a `policy` block, a new `PolicyEngine` instance will be created using these settings. This engine will be used by the subagent's `ToolRegistry`.
|
||||
4. **Tool Registry Scoping**: The subagent's `ToolRegistry` will be initialized with the scoped `PolicyEngine`. It will prioritize tools explicitly defined in the subagent's `tools` list and `mcp_servers` block.
|
||||
|
||||
## 3. Extension Authors and Private MCP Tools
|
||||
Extension authors can now define MCP servers that are only available to specific agents within that extension, hiding them from the primary agent.
|
||||
|
||||
### `gemini-extension.json` Updates
|
||||
MCP servers in extensions can now specify a `visibility` field.
|
||||
```json
|
||||
{
|
||||
"name": "security-extension",
|
||||
"mcpServers": {
|
||||
"private-audit-tool": {
|
||||
"command": "...",
|
||||
"visibility": "private"
|
||||
}
|
||||
},
|
||||
"agents": [
|
||||
{
|
||||
"name": "auditor",
|
||||
"path": "agents/auditor.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
- **`visibility: "public"` (default)**: Registered globally and available to the primary agent.
|
||||
- **`visibility: "private"`**: Not registered globally. The `ExtensionManager` will attach these server configurations to the agents loaded from the same extension.
|
||||
|
||||
## 4. Component Updates
|
||||
|
||||
### Agent Registry (`packages/core/src/agents/registry.ts`)
|
||||
- Update `AgentDefinition` and its internal storage to hold the scoped `policy` and private `mcpServers`.
|
||||
- Ensure `registerAgent` handles the ingestion of these new fields.
|
||||
|
||||
### Tool Registry / Dispatcher (`packages/core/src/tools/tool-registry.ts`)
|
||||
- The registry will now correctly filter tools based on the scoped `PolicyEngine` provided during construction.
|
||||
- It will support dynamic registration of subagent-specific MCP servers without affecting the global registry.
|
||||
|
||||
### Configuration Parser (`packages/core/src/agents/agentLoader.ts`)
|
||||
- Update `FrontmatterLocalAgentDefinition` and `localAgentSchema` (Zod) to include the `policy` and `mcp_servers` fields.
|
||||
- Update `markdownToAgentDefinition` to map these fields to the internal `AgentDefinition`.
|
||||
|
||||
## 5. Runtime Enforcement
|
||||
Boundaries are enforced at multiple levels:
|
||||
1. **Discovery Boundary**: The primary agent's `ToolRegistry` never sees "private" or "internal" MCP tools.
|
||||
2. **Policy Boundary**: The subagent's `PolicyEngine` independently evaluates every tool call against the subagent's specific `allow`/`exclude` rules.
|
||||
3. **Execution Boundary**: `LocalAgentExecutor` validates all incoming `function_call` requests against the subagent's scoped `ToolRegistry` before scheduling execution.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Roadmap
|
||||
|
||||
### Phase 1: Core Types and Parsing
|
||||
- [ ] Update `AgentDefinition` and `LocalAgentDefinition` types in `core/agents/types.ts`.
|
||||
- [ ] Update Zod schemas and parsing logic in `core/agents/agentLoader.ts`.
|
||||
- [ ] Add `visibility` field to `MCPServerConfig` in `core/config/config.ts`.
|
||||
|
||||
### Phase 2: Configuration Scoping
|
||||
- [ ] Implement `Config.createScopedConfig()` in `core/config/config.ts`.
|
||||
- [ ] Update `PolicyEngine` to support initialization from `PolicySettings`.
|
||||
|
||||
### Phase 3: Isolated Execution
|
||||
- [ ] Modify `LocalAgentExecutor.create` to use scoped config and policy.
|
||||
- [ ] Implement logic to start and register subagent-specific MCP servers in the isolated `ToolRegistry`.
|
||||
- [ ] Update `ExtensionManager` in `cli/config/extension-manager.ts` to handle private MCP server visibility.
|
||||
|
||||
### Phase 4: Validation and Testing
|
||||
- [ ] Add unit tests for scoped policy enforcement.
|
||||
- [ ] Add integration tests for subagent-specific MCP tools.
|
||||
- [ ] Verify that private tools are not leaked to the primary agent's `ToolRegistry`.
|
||||
Reference in New Issue
Block a user