fix(browser): gate response fulfillment opt-in

Squash merge PR #1295 into dev.
This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-19 08:41:00 -04:00
committed by GitHub
parent eaa36d5167
commit 538b6444ff
3 changed files with 96 additions and 10 deletions
+6
View File
@@ -127,12 +127,18 @@ CCS still supports environment-variable overrides for backward compatibility.
| `CCS_BROWSER_USER_DATA_DIR` | Preferred override for Claude Browser Attach user-data dir |
| `CCS_BROWSER_PROFILE_DIR` | Legacy alias for the same attach directory |
| `CCS_BROWSER_DEVTOOLS_PORT` | Explicit DevTools port override |
| `CCS_BROWSER_INTERCEPT_FULFILL_MODE=enabled` | Dangerous local-testing opt-in for Browser MCP response fulfillment; disabled by default |
| `CCS_BROWSER_UPLOAD_ROOTS` | Optional `path.delimiter`-separated allowlist for local files that browser upload tools may read |
| `CCS_BROWSER_DOWNLOAD_ROOTS` | Optional `path.delimiter`-separated allowlist for caller-provided browser download directories |
If an override is active, Browser status surfaces should report that the current session is being
managed externally by environment variables.
Browser MCP request interception can continue or fail matched requests by default. Synthetic response
fulfillment (`action: fulfill`) is more sensitive because it can serve caller-supplied response
content inside the attached browser's target origin. CCS therefore hides and blocks fulfillment unless
`CCS_BROWSER_INTERCEPT_FULFILL_MODE=enabled` is set for a trusted local test session.
The saved browser policy still controls default exposure. Env overrides change the effective attach
path/port for the current shell; they do not bypass `policy: manual`.
+23 -2
View File
@@ -639,7 +639,7 @@ function getTools() {
},
},
priority: { type: 'integer' },
action: { type: 'string', enum: ['continue', 'fail', 'fulfill'] },
action: { type: 'string', enum: getInterceptActionEnum() },
statusCode: { type: 'integer', minimum: 100, maximum: 599 },
responseHeaders: {
type: 'array',
@@ -1292,9 +1292,30 @@ function parseOptionalPageId(toolArgs) {
: '';
}
function getBrowserInterceptFulfillMode() {
return String(process.env.CCS_BROWSER_INTERCEPT_FULFILL_MODE || 'disabled').trim() === 'enabled'
? 'enabled'
: 'disabled';
}
function isBrowserInterceptFulfillEnabled() {
return getBrowserInterceptFulfillMode() === 'enabled';
}
function getInterceptActionEnum() {
return isBrowserInterceptFulfillEnabled()
? ['continue', 'fail', 'fulfill']
: ['continue', 'fail'];
}
function parseInterceptAction(value) {
if (value === 'fulfill' && !isBrowserInterceptFulfillEnabled()) {
throw new Error(
'action fulfill is disabled by CCS_BROWSER_INTERCEPT_FULFILL_MODE=disabled; set it to enabled only for trusted local testing'
);
}
if (value !== 'continue' && value !== 'fail' && value !== 'fulfill') {
throw new Error('action must be one of: continue, fail, fulfill');
throw new Error(`action must be one of: ${getInterceptActionEnum().join(', ')}`);
}
return value;
}
@@ -3,6 +3,8 @@ import { runMcpRequests, getResponseText } from './browser-mcp-test-harness';
import type { MockPageState } from './browser-mcp-test-harness';
describe('ccs-browser MCP server - session and interception', () => {
const fulfillEnabledEnv = { CCS_BROWSER_INTERCEPT_FULFILL_MODE: 'enabled' };
it('lists browser tools including navigate, click, type, and screenshot', async () => {
const responses = await runMcpRequests(
[{ id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/' }],
@@ -14,7 +16,9 @@ describe('ccs-browser MCP server - session and interception', () => {
tools: Array<{
name: string;
description?: string;
inputSchema?: { properties?: Record<string, { type?: string; minimum?: number }> };
inputSchema?: {
properties?: Record<string, { type?: string; minimum?: number; enum?: string[] }>;
};
}>;
}
).tools;
@@ -106,7 +110,10 @@ describe('ccs-browser MCP server - session and interception', () => {
expect(addRuleTool?.inputSchema?.properties?.urlRegex).toMatchObject({ type: 'string' });
expect(addRuleTool?.inputSchema?.properties?.headerMatchers).toMatchObject({ type: 'array' });
expect(addRuleTool?.inputSchema?.properties?.priority).toMatchObject({ type: 'integer' });
expect(addRuleTool?.inputSchema?.properties?.action).toMatchObject({ type: 'string' });
expect(addRuleTool?.inputSchema?.properties?.action).toMatchObject({
type: 'string',
enum: ['continue', 'fail'],
});
expect(addRuleTool?.inputSchema?.properties?.statusCode).toMatchObject({ type: 'integer' });
expect(addRuleTool?.inputSchema?.properties?.responseHeaders).toMatchObject({ type: 'array' });
expect(addRuleTool?.inputSchema?.properties?.headers).toBeUndefined();
@@ -1148,6 +1155,7 @@ describe('ccs-browser MCP server - session and interception', () => {
},
],
{
childEnv: fulfillEnabledEnv,
responseTimeoutMs: 12000,
}
);
@@ -1217,6 +1225,7 @@ describe('ccs-browser MCP server - session and interception', () => {
},
],
{
childEnv: fulfillEnabledEnv,
responseTimeoutMs: 12000,
}
);
@@ -1372,6 +1381,7 @@ describe('ccs-browser MCP server - session and interception', () => {
},
],
{
childEnv: fulfillEnabledEnv,
responseTimeoutMs: 12000,
}
);
@@ -1435,6 +1445,46 @@ describe('ccs-browser MCP server - session and interception', () => {
expect(pages[0]?.intercept?.continuedRequestIds).toContain('req-header-regex');
});
it('exposes fulfill interception only with the explicit dangerous opt-in', async () => {
const responses = await runMcpRequests(
[{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }],
[
{ jsonrpc: '2.0', id: 958, method: 'tools/list' },
{
jsonrpc: '2.0',
id: 959,
method: 'tools/call',
params: {
name: 'browser_add_intercept_rule',
arguments: {
urlIncludes: '/api/mock',
action: 'fulfill',
statusCode: 200,
body: 'blocked',
},
},
},
]
);
const tools = (
responses.find((message) => message.id === 958)?.result as {
tools: Array<{
name: string;
inputSchema?: { properties?: Record<string, { enum?: string[] }> };
}>;
}
).tools;
const addRuleTool = tools.find((tool) => tool.name === 'browser_add_intercept_rule');
expect(addRuleTool?.inputSchema?.properties?.action?.enum).toEqual(['continue', 'fail']);
const response = responses.find((message) => message.id === 959);
expect((response?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(response)).toContain(
'Browser MCP failed: action fulfill is disabled by CCS_BROWSER_INTERCEPT_FULFILL_MODE=disabled'
);
});
it('adds a fulfill interception rule and lists its response summary', async () => {
const responses = await runMcpRequests(
[{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }],
@@ -1461,7 +1511,8 @@ describe('ccs-browser MCP server - session and interception', () => {
method: 'tools/call',
params: { name: 'browser_list_intercept_rules', arguments: {} },
},
]
],
{ childEnv: fulfillEnabledEnv }
);
const listText = getResponseText(responses.find((message) => message.id === 962));
@@ -1515,6 +1566,7 @@ describe('ccs-browser MCP server - session and interception', () => {
},
],
{
childEnv: fulfillEnabledEnv,
responseTimeoutMs: 12000,
}
);
@@ -1577,6 +1629,7 @@ describe('ccs-browser MCP server - session and interception', () => {
},
],
{
childEnv: fulfillEnabledEnv,
responseTimeoutMs: 12000,
}
);
@@ -1610,7 +1663,8 @@ describe('ccs-browser MCP server - session and interception', () => {
},
},
},
]
],
{ childEnv: fulfillEnabledEnv }
);
const addText = getResponseText(responses.find((message) => message.id === 967));
@@ -1636,7 +1690,8 @@ describe('ccs-browser MCP server - session and interception', () => {
},
},
},
]
],
{ childEnv: fulfillEnabledEnv }
);
const response = responses.find((message) => message.id === 968);
@@ -1664,7 +1719,8 @@ describe('ccs-browser MCP server - session and interception', () => {
},
},
},
]
],
{ childEnv: fulfillEnabledEnv }
);
const response = responses.find((message) => message.id === 969);
@@ -1692,7 +1748,8 @@ describe('ccs-browser MCP server - session and interception', () => {
},
},
},
]
],
{ childEnv: fulfillEnabledEnv }
);
const response = responses.find((message) => message.id === 970);
@@ -1719,7 +1776,8 @@ describe('ccs-browser MCP server - session and interception', () => {
},
},
},
]
],
{ childEnv: fulfillEnabledEnv }
);
const response = responses.find((message) => message.id === 971);
@@ -1885,6 +1943,7 @@ describe('ccs-browser MCP server - session and interception', () => {
},
],
{
childEnv: fulfillEnabledEnv,
responseTimeoutMs: 12000,
}
);