feat(agent): add allowed domain restrictions for browser agent (#21775)

This commit is contained in:
cynthialong0-0
2026-03-13 12:41:40 -07:00
committed by GitHub
parent dd8d4c98b3
commit bfbd3c40a7
8 changed files with 283 additions and 3 deletions

View File

@@ -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`

View File

@@ -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',

View File

@@ -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,
),
},
};
};

View File

@@ -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:');
});
});

View File

@@ -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: {

View File

@@ -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

View File

@@ -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,
},
};

View File

@@ -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.",