diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 4e0e9856d9..4b53866247 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -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` diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 7d47d66e32..0e7b88d76d 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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', diff --git a/packages/core/src/agents/browser/browserAgentDefinition.ts b/packages/core/src/agents/browser/browserAgentDefinition.ts index 2703f53930..629019eced 100644 --- a/packages/core/src/agents/browser/browserAgentDefinition.ts +++ b/packages/core/src/agents/browser/browserAgentDefinition.ts @@ -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 = ( 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, + ), }, }; }; diff --git a/packages/core/src/agents/browser/browserAgentFactory.test.ts b/packages/core/src/agents/browser/browserAgentFactory.test.ts index c7d7b1a6b0..bbc317a282 100644 --- a/packages/core/src/agents/browser/browserAgentFactory.test.ts +++ b/packages/core/src/agents/browser/browserAgentFactory.test.ts @@ -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:'); + }); }); diff --git a/packages/core/src/agents/browser/browserManager.test.ts b/packages/core/src/agents/browser/browserManager.test.ts index 68eafc6e31..f053e231e2 100644 --- a/packages/core/src/agents/browser/browserManager.test.ts +++ b/packages/core/src/agents/browser/browserManager.test.ts @@ -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: { diff --git a/packages/core/src/agents/browser/browserManager.ts b/packages/core/src/agents/browser/browserManager.ts index 426a6cec70..63b5cff89a 100644 --- a/packages/core/src/agents/browser/browserManager.ts +++ b/packages/core/src/agents/browser/browserManager.ts @@ -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 | 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 diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index bfdd6fdf42..e97d4859f2 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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, }, }; diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 4e3631fc2c..f8fc341af8 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -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.",