mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-17 17:41:24 -07:00
feat(agent): add allowed domain restrictions for browser agent (#21775)
This commit is contained in:
@@ -706,6 +706,17 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`agents.browser.allowedDomains`** (array):
|
||||
- **Description:** A list of allowed domains for the browser agent (e.g.,
|
||||
["github.com", "*.google.com"]).
|
||||
- **Default:**
|
||||
|
||||
```json
|
||||
["github.com", "*.google.com", "localhost"]
|
||||
```
|
||||
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`agents.browser.disableUserInput`** (boolean):
|
||||
- **Description:** Disable user input on browser window during automation.
|
||||
- **Default:** `true`
|
||||
|
||||
@@ -1117,6 +1117,19 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Model override for the visual agent.',
|
||||
showInDialog: false,
|
||||
},
|
||||
allowedDomains: {
|
||||
type: 'array',
|
||||
label: 'Allowed Domains',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: ['github.com', '*.google.com', 'localhost'] as string[],
|
||||
description: oneLine`
|
||||
A list of allowed domains for the browser agent
|
||||
(e.g., ["github.com", "*.google.com"]).
|
||||
`,
|
||||
showInDialog: false,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
disableUserInput: {
|
||||
type: 'boolean',
|
||||
label: 'Disable User Input',
|
||||
|
||||
@@ -53,9 +53,22 @@ When you need to identify elements by visual attributes not in the AX tree (e.g.
|
||||
* Extracted from prototype (computer_use_subagent_cdt branch).
|
||||
*
|
||||
* @param visionEnabled Whether visual tools (analyze_screenshot, click_at) are available.
|
||||
* @param allowedDomains Optional list of allowed domains to restrict navigation.
|
||||
*/
|
||||
export function buildBrowserSystemPrompt(visionEnabled: boolean): string {
|
||||
return `You are an expert browser automation agent (Orchestrator). Your goal is to completely fulfill the user's request.
|
||||
export function buildBrowserSystemPrompt(
|
||||
visionEnabled: boolean,
|
||||
allowedDomains?: string[],
|
||||
): string {
|
||||
const allowedDomainsInstruction =
|
||||
allowedDomains && allowedDomains.length > 0
|
||||
? `\n\nSECURITY DOMAIN RESTRICTION - CRITICAL:\nYou are strictly limited to the following allowed domains (and their subdomains if specified with '*.'):\n${allowedDomains
|
||||
.map((d) => `- ${d}`)
|
||||
.join(
|
||||
'\n',
|
||||
)}\nDo NOT attempt to navigate to any other domains using new_page or navigate_page, as it will be rejected. This is a hard security constraint.`
|
||||
: '';
|
||||
|
||||
return `You are an expert browser automation agent (Orchestrator). Your goal is to completely fulfill the user's request.${allowedDomainsInstruction}
|
||||
|
||||
IMPORTANT: You will receive an accessibility tree snapshot showing elements with uid values (e.g., uid=87_4 button "Login").
|
||||
Use these uid values directly with your tools:
|
||||
@@ -166,7 +179,10 @@ export const BrowserAgentDefinition = (
|
||||
</task>
|
||||
|
||||
First, use new_page to open the relevant URL. Then call take_snapshot to see the page and proceed with your task.`,
|
||||
systemPrompt: buildBrowserSystemPrompt(visionEnabled),
|
||||
systemPrompt: buildBrowserSystemPrompt(
|
||||
visionEnabled,
|
||||
config.getBrowserAgentConfig().customConfig.allowedDomains,
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -239,6 +239,25 @@ describe('browserAgentFactory', () => {
|
||||
expect(toolNames).toContain('analyze_screenshot');
|
||||
});
|
||||
|
||||
it('should include domain restrictions in system prompt when configured', async () => {
|
||||
const configWithDomains = makeFakeConfig({
|
||||
agents: {
|
||||
browser: {
|
||||
allowedDomains: ['restricted.com'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { definition } = await createBrowserAgentDefinition(
|
||||
configWithDomains,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const systemPrompt = definition.promptConfig?.systemPrompt ?? '';
|
||||
expect(systemPrompt).toContain('SECURITY DOMAIN RESTRICTION - CRITICAL:');
|
||||
expect(systemPrompt).toContain('- restricted.com');
|
||||
});
|
||||
|
||||
it('should include all MCP navigation tools (new_page, navigate_page) in definition', async () => {
|
||||
mockBrowserManager.getDiscoveredTools.mockResolvedValue([
|
||||
{ name: 'take_snapshot', description: 'Take snapshot' },
|
||||
@@ -323,4 +342,22 @@ describe('buildBrowserSystemPrompt', () => {
|
||||
expect(prompt).toContain('complete_task');
|
||||
}
|
||||
});
|
||||
|
||||
it('should include allowed domains restriction when provided', () => {
|
||||
const prompt = buildBrowserSystemPrompt(false, [
|
||||
'github.com',
|
||||
'*.google.com',
|
||||
]);
|
||||
expect(prompt).toContain('SECURITY DOMAIN RESTRICTION - CRITICAL:');
|
||||
expect(prompt).toContain('- github.com');
|
||||
expect(prompt).toContain('- *.google.com');
|
||||
});
|
||||
|
||||
it('should exclude allowed domains restriction when not provided or empty', () => {
|
||||
let prompt = buildBrowserSystemPrompt(false);
|
||||
expect(prompt).not.toContain('SECURITY DOMAIN RESTRICTION - CRITICAL:');
|
||||
|
||||
prompt = buildBrowserSystemPrompt(false, []);
|
||||
expect(prompt).not.toContain('SECURITY DOMAIN RESTRICTION - CRITICAL:');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,6 +143,75 @@ describe('BrowserManager', () => {
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should block navigate_page to disallowed domain', async () => {
|
||||
const restrictedConfig = makeFakeConfig({
|
||||
agents: {
|
||||
browser: {
|
||||
allowedDomains: ['google.com'],
|
||||
},
|
||||
},
|
||||
});
|
||||
const manager = new BrowserManager(restrictedConfig);
|
||||
const result = await manager.callTool('navigate_page', {
|
||||
url: 'https://evil.com',
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect((result.content || [])[0]?.text).toContain('not permitted');
|
||||
expect(Client).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow navigate_page to allowed domain', async () => {
|
||||
const restrictedConfig = makeFakeConfig({
|
||||
agents: {
|
||||
browser: {
|
||||
allowedDomains: ['google.com'],
|
||||
},
|
||||
},
|
||||
});
|
||||
const manager = new BrowserManager(restrictedConfig);
|
||||
const result = await manager.callTool('navigate_page', {
|
||||
url: 'https://google.com/search',
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(false);
|
||||
expect((result.content || [])[0]?.text).toBe('Tool result');
|
||||
});
|
||||
|
||||
it('should allow navigate_page to subdomain when wildcard is used', async () => {
|
||||
const restrictedConfig = makeFakeConfig({
|
||||
agents: {
|
||||
browser: {
|
||||
allowedDomains: ['*.google.com'],
|
||||
},
|
||||
},
|
||||
});
|
||||
const manager = new BrowserManager(restrictedConfig);
|
||||
const result = await manager.callTool('navigate_page', {
|
||||
url: 'https://mail.google.com',
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(false);
|
||||
expect((result.content || [])[0]?.text).toBe('Tool result');
|
||||
});
|
||||
|
||||
it('should block new_page to disallowed domain', async () => {
|
||||
const restrictedConfig = makeFakeConfig({
|
||||
agents: {
|
||||
browser: {
|
||||
allowedDomains: ['google.com'],
|
||||
},
|
||||
},
|
||||
});
|
||||
const manager = new BrowserManager(restrictedConfig);
|
||||
const result = await manager.callTool('new_page', {
|
||||
url: 'https://evil.com',
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect((result.content || [])[0]?.text).toContain('not permitted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP connection', () => {
|
||||
@@ -172,6 +241,40 @@ describe('BrowserManager', () => {
|
||||
expect(args[userDataDirIndex + 1]).toMatch(/cli-browser-profile$/);
|
||||
});
|
||||
|
||||
it('should pass --host-rules when allowedDomains is configured', async () => {
|
||||
const restrictedConfig = makeFakeConfig({
|
||||
agents: {
|
||||
browser: {
|
||||
allowedDomains: ['google.com', '*.openai.com'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const manager = new BrowserManager(restrictedConfig);
|
||||
await manager.ensureConnection();
|
||||
|
||||
const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
|
||||
?.args as string[];
|
||||
expect(args).toContain(
|
||||
'--chromeArg="--host-rules=MAP * 127.0.0.1, EXCLUDE google.com, EXCLUDE *.openai.com, EXCLUDE 127.0.0.1"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when invalid domain is configured in allowedDomains', async () => {
|
||||
const invalidConfig = makeFakeConfig({
|
||||
agents: {
|
||||
browser: {
|
||||
allowedDomains: ['invalid domain!'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const manager = new BrowserManager(invalidConfig);
|
||||
await expect(manager.ensureConnection()).rejects.toThrow(
|
||||
'Invalid domain in allowedDomains: invalid domain!',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass headless flag when configured', async () => {
|
||||
const headlessConfig = makeFakeConfig({
|
||||
agents: {
|
||||
|
||||
@@ -147,6 +147,19 @@ export class BrowserManager {
|
||||
throw signal.reason ?? new Error('Operation cancelled');
|
||||
}
|
||||
|
||||
const errorMessage = this.checkNavigationRestrictions(toolName, args);
|
||||
if (errorMessage) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: errorMessage,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const client = await this.getRawMcpClient();
|
||||
const callPromise = client.callTool(
|
||||
{ name: toolName, arguments: args },
|
||||
@@ -342,6 +355,23 @@ export class BrowserManager {
|
||||
mcpArgs.push('--userDataDir', defaultProfilePath);
|
||||
}
|
||||
|
||||
if (
|
||||
browserConfig.customConfig.allowedDomains &&
|
||||
browserConfig.customConfig.allowedDomains.length > 0
|
||||
) {
|
||||
const exclusionRules = browserConfig.customConfig.allowedDomains
|
||||
.map((domain) => {
|
||||
if (!/^(\*\.)?([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+$/.test(domain)) {
|
||||
throw new Error(`Invalid domain in allowedDomains: ${domain}`);
|
||||
}
|
||||
return `EXCLUDE ${domain}`;
|
||||
})
|
||||
.join(', ');
|
||||
mcpArgs.push(
|
||||
`--chromeArg="--host-rules=MAP * 127.0.0.1, ${exclusionRules}, EXCLUDE 127.0.0.1"`,
|
||||
);
|
||||
}
|
||||
|
||||
debugLogger.log(
|
||||
`Launching chrome-devtools-mcp (${sessionMode} mode) with args: ${mcpArgs.join(' ')}`,
|
||||
);
|
||||
@@ -502,6 +532,63 @@ export class BrowserManager {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check navigation restrictions based on tools and the args sent
|
||||
* along with them.
|
||||
*
|
||||
* @returns error message if failed, undefined if passed.
|
||||
*/
|
||||
private checkNavigationRestrictions(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
const pageNavigationTools = ['navigate_page', 'new_page'];
|
||||
|
||||
if (!pageNavigationTools.includes(toolName)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const allowedDomains =
|
||||
this.config.getBrowserAgentConfig().customConfig.allowedDomains;
|
||||
if (!allowedDomains || allowedDomains.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const url = args['url'];
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof url !== 'string') {
|
||||
return `Invalid URL: URL must be a string.`;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
const urlHostname = parsedUrl.hostname.replace(/\.$/, '');
|
||||
|
||||
for (const domainPattern of allowedDomains) {
|
||||
if (domainPattern.startsWith('*.')) {
|
||||
const baseDomain = domainPattern.substring(2);
|
||||
if (
|
||||
urlHostname === baseDomain ||
|
||||
urlHostname.endsWith(`.${baseDomain}`)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
if (urlHostname === domainPattern) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return `Invalid URL: Malformed URL string.`;
|
||||
}
|
||||
|
||||
// If none matched, then deny
|
||||
return `Tool '${toolName}' is not permitted for the requested URL/domain based on your current browser settings.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a fallback notification handler on the MCP client to
|
||||
* automatically re-inject the input blocker after any server-side
|
||||
|
||||
@@ -316,6 +316,8 @@ export interface BrowserAgentCustomConfig {
|
||||
profilePath?: string;
|
||||
/** Model override for the visual agent. */
|
||||
visualModel?: string;
|
||||
/** List of allowed domains for the browser agent (e.g., ["github.com", "*.google.com"]). */
|
||||
allowedDomains?: string[];
|
||||
/** Disable user input on the browser window during automation. Default: true in non-headless mode */
|
||||
disableUserInput?: boolean;
|
||||
}
|
||||
@@ -2902,6 +2904,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
headless: customConfig.headless ?? false,
|
||||
profilePath: customConfig.profilePath,
|
||||
visualModel: customConfig.visualModel,
|
||||
allowedDomains: customConfig.allowedDomains,
|
||||
disableUserInput: customConfig.disableUserInput,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1188,6 +1188,16 @@
|
||||
"markdownDescription": "Model override for the visual agent.\n\n- Category: `Advanced`\n- Requires restart: `yes`",
|
||||
"type": "string"
|
||||
},
|
||||
"allowedDomains": {
|
||||
"title": "Allowed Domains",
|
||||
"description": "A list of allowed domains for the browser agent (e.g., [\"github.com\", \"*.google.com\"]).",
|
||||
"markdownDescription": "A list of allowed domains for the browser agent (e.g., [\"github.com\", \"*.google.com\"]).\n\n- Category: `Advanced`\n- Requires restart: `yes`\n- Default: `[\n \"github.com\",\n \"*.google.com\",\n \"localhost\"\n]`",
|
||||
"default": ["github.com", "*.google.com", "localhost"],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"disableUserInput": {
|
||||
"title": "Disable User Input",
|
||||
"description": "Disable user input on browser window during automation.",
|
||||
|
||||
Reference in New Issue
Block a user