From 538b6444ff5cdf5109ab15ccc202baec27bff916 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Tue, 19 May 2026 08:41:00 -0400 Subject: [PATCH] fix(browser): gate response fulfillment opt-in Squash merge PR #1295 into dev. --- docs/browser-automation.md | 6 ++ lib/mcp/ccs-browser-server.cjs | 25 ++++++- ...browser-mcp-session-and-intercepts.test.ts | 75 +++++++++++++++++-- 3 files changed, 96 insertions(+), 10 deletions(-) diff --git a/docs/browser-automation.md b/docs/browser-automation.md index 759a3837..4c230d23 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -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`. diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index dbe56fab..8032197e 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -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; } diff --git a/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts b/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts index 0c2f8a33..5d324aa7 100644 --- a/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts +++ b/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts @@ -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 }; + inputSchema?: { + properties?: Record; + }; }>; } ).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 }; + }>; + } + ).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, } );