security(browser-mcp): restrict model-callable local file uploads/downloads to safe roots and deny sensitive paths (#1220)

* fix(browser): restrict file transfer paths

* fix(browser): normalize safe transfer paths

* fix(browser): reject sensitive transfer roots
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-05-12 08:57:33 -04:00
1 parent f7d5d08bbf
commit eb4bbfddf3
4 files changed
+402 -76

No files matched your search

+19 -1
View File
@@ -1,6 +1,6 @@
# Browser Automation # Browser Automation
Last Updated: 2026-04-19 Last Updated: 2026-05-11
CCS provides browser automation through two separate runtime paths: CCS provides browser automation through two separate runtime paths:
@@ -127,6 +127,8 @@ 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_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_PROFILE_DIR` | Legacy alias for the same attach directory |
| `CCS_BROWSER_DEVTOOLS_PORT` | Explicit DevTools port override | | `CCS_BROWSER_DEVTOOLS_PORT` | Explicit DevTools port override |
| `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 If an override is active, Browser status surfaces should report that the current session is being
managed externally by environment variables. managed externally by environment variables.
@@ -144,6 +146,22 @@ Config-backed Browser Attach always passes an explicit DevTools port to the runt
effective value is the default `9222`. Metadata-based port discovery is preserved only for the effective value is the default `9222`. Metadata-based port discovery is preserved only for the
legacy `CCS_BROWSER_PROFILE_DIR` flow when `CCS_BROWSER_DEVTOOLS_PORT` is not set. legacy `CCS_BROWSER_PROFILE_DIR` flow when `CCS_BROWSER_DEVTOOLS_PORT` is not set.
### Browser File Transfer Safety
Claude Browser Attach file-transfer tools intentionally use a deny-by-default filesystem boundary:
- Downloads without an explicit `downloadPath` go to a CCS-created temporary session directory.
- A caller-provided `downloadPath` must be inside that temporary session directory or inside one of
the directories listed in `CCS_BROWSER_DOWNLOAD_ROOTS`.
- Local upload and drag-and-drop files must be inside the temporary session download directory or
inside one of the directories listed in `CCS_BROWSER_UPLOAD_ROOTS`.
- Hidden path segments and common secret locations/files, such as `.ssh`, `.aws`, `.ccs`,
`.claude`, `.env`, and private-key filenames, are rejected even inside an allowed root.
- Each file-transfer call is limited to 10 files, and each local file must be at most 10 MiB.
Set upload/download roots only to purpose-built scratch directories. Do not point these variables at
your home directory, a source checkout with secrets, or a real cloud/tooling config directory.
## Managed Runtime Files ## Managed Runtime Files
- `~/.claude.json` -> CCS manages `mcpServers.ccs-browser` for Claude Browser Attach - `~/.claude.json` -> CCS manages `mcpServers.ccs-browser` for Claude Browser Attach
+154 -7
View File
@@ -151,6 +151,8 @@ const DEFAULT_DRAG_STEPS = 5;
const MAX_POINTER_ACTIONS = 25; const MAX_POINTER_ACTIONS = 25;
const SESSION_START_SETTLE_WINDOW_MS = 250; const SESSION_START_SETTLE_WINDOW_MS = 250;
const MAX_ARTIFACT_FILE_BYTES = 5 * 1024 * 1024; const MAX_ARTIFACT_FILE_BYTES = 5 * 1024 * 1024;
const MAX_LOCAL_TRANSFER_FILE_BYTES = 10 * 1024 * 1024;
const MAX_LOCAL_TRANSFER_FILES = 10;
const SAFE_ARTIFACT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; const SAFE_ARTIFACT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const SESSION_CANCELED_ERROR_CODE = 'SESSION_CANCELED'; const SESSION_CANCELED_ERROR_CODE = 'SESSION_CANCELED';
@@ -177,6 +179,37 @@ const recentDownloads = [];
const interceptSessionsByPageId = new Map(); const interceptSessionsByPageId = new Map();
let browserDownloadSession = null; let browserDownloadSession = null;
let sessionDownloadDir = ''; let sessionDownloadDir = '';
const SENSITIVE_LOCAL_PATH_SEGMENTS = new Set([
'.ssh',
'.gnupg',
'.aws',
'.azure',
'.kube',
'.docker',
'.npmrc',
'.netrc',
'.pypirc',
'.config',
'.claude',
'.ccs',
]);
const SENSITIVE_LOCAL_FILE_NAMES = new Set([
'.env',
'id_rsa',
'id_dsa',
'id_ecdsa',
'id_ed25519',
'known_hosts',
'authorized_keys',
'credentials',
'credentials.json',
'config.json',
'settings.json',
'history',
'.bash_history',
'.zsh_history',
'.fish_history',
]);
const MAX_RECENT_REQUESTS = 100; const MAX_RECENT_REQUESTS = 100;
const MAX_RECENT_DOWNLOADS = 100; const MAX_RECENT_DOWNLOADS = 100;
const FETCH_FAIL_ERROR_REASON = 'Failed'; const FETCH_FAIL_ERROR_REASON = 'Failed';
@@ -1438,10 +1471,105 @@ function getSessionDownloadPath() {
return sessionDownloadDir; return sessionDownloadDir;
} }
function splitConfiguredPathRoots(value) {
return String(value || '')
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => path.resolve(entry));
}
function getDownloadSafeRoots() {
return [
getSessionDownloadPath(),
...splitConfiguredPathRoots(process.env.CCS_BROWSER_DOWNLOAD_ROOTS),
];
}
function getUploadSafeRoots() {
return [
getSessionDownloadPath(),
...splitConfiguredPathRoots(process.env.CCS_BROWSER_UPLOAD_ROOTS),
];
}
function getNearestExistingAncestor(candidatePath) {
let currentPath = candidatePath;
while (!fs.existsSync(currentPath)) {
const parentPath = path.dirname(currentPath);
if (parentPath === currentPath) {
return currentPath;
}
currentPath = parentPath;
}
return currentPath;
}
function resolveExistingRoot(rootPath) {
fs.mkdirSync(rootPath, { recursive: true });
return fs.realpathSync(rootPath);
}
function resolvePathWithRealAncestor(candidatePath) {
const resolvedPath = path.resolve(candidatePath);
const ancestorPath = getNearestExistingAncestor(resolvedPath);
const realAncestorPath = fs.realpathSync(ancestorPath);
const relativeSuffix = path.relative(ancestorPath, resolvedPath);
return relativeSuffix ? path.resolve(realAncestorPath, relativeSuffix) : realAncestorPath;
}
function isPathInsideRoot(candidatePath, rootPath) {
const relativePath = path.relative(rootPath, candidatePath);
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
}
function findContainingRoot(candidatePath, rootPaths) {
return rootPaths.find((rootPath) => isPathInsideRoot(candidatePath, rootPath)) || '';
}
function getLocalPathSegments(candidatePath, rootPath) {
const rootSegments = path.resolve(rootPath).split(path.sep).filter(Boolean);
const relativePath = path.relative(rootPath, candidatePath);
const relativeSegments = relativePath.split(path.sep).filter(Boolean);
return [...rootSegments, ...relativeSegments];
}
function assertNoSensitiveLocalPathSegments(candidatePath, rootPath, label) {
const segments = getLocalPathSegments(candidatePath, rootPath);
for (const segment of segments) {
const normalizedSegment = segment.toLowerCase();
if (normalizedSegment.startsWith('.') || SENSITIVE_LOCAL_PATH_SEGMENTS.has(normalizedSegment)) {
throw new Error(`${label} cannot include hidden or sensitive path segment: ${segment}`);
}
}
const fileName = path.basename(candidatePath).toLowerCase();
if (SENSITIVE_LOCAL_FILE_NAMES.has(fileName)) {
throw new Error(
`${label} cannot reference sensitive file name: ${path.basename(candidatePath)}`
);
}
}
function ensureWritableDirectory(downloadPath) { function ensureWritableDirectory(downloadPath) {
fs.mkdirSync(downloadPath, { recursive: true }); const resolvedPath = path.resolve(downloadPath);
fs.accessSync(downloadPath, fs.constants.W_OK); const candidatePath = resolvePathWithRealAncestor(resolvedPath);
return downloadPath; const safeRoots = getDownloadSafeRoots().map(resolveExistingRoot);
const containingRoot = findContainingRoot(candidatePath, safeRoots);
if (!containingRoot) {
throw new Error(
'downloadPath must be inside the browser session download directory or a CCS_BROWSER_DOWNLOAD_ROOTS entry'
);
}
assertNoSensitiveLocalPathSegments(candidatePath, containingRoot, 'downloadPath');
fs.mkdirSync(resolvedPath, { recursive: true });
const realDownloadPath = fs.realpathSync(resolvedPath);
if (!isPathInsideRoot(realDownloadPath, containingRoot)) {
throw new Error('downloadPath cannot traverse outside the allowed download root');
}
fs.accessSync(realDownloadPath, fs.constants.W_OK);
return realDownloadPath;
} }
function pushRecentDownload(entry) { function pushRecentDownload(entry) {
@@ -2418,16 +2546,35 @@ function buildFileInputHandleExpression(selector, nth, frameSelector, pierceShad
} }
function validateLocalFiles(files) { function validateLocalFiles(files) {
if (files.length > MAX_LOCAL_TRANSFER_FILES) {
throw new Error(`files exceeds maximum of ${MAX_LOCAL_TRANSFER_FILES}`);
}
const safeRoots = getUploadSafeRoots().map(resolveExistingRoot);
return files.map((filePath) => { return files.map((filePath) => {
const resolvedPath = path.resolve(filePath); const resolvedPath = path.resolve(filePath);
if (!fs.existsSync(resolvedPath)) { if (!fs.existsSync(resolvedPath)) {
throw new Error(`file does not exist: ${resolvedPath}`); throw new Error(`file does not exist: ${resolvedPath}`);
} }
const stat = fs.statSync(resolvedPath); const realFilePath = fs.realpathSync(resolvedPath);
if (!stat.isFile()) { const containingRoot = findContainingRoot(realFilePath, safeRoots);
throw new Error(`file is not a regular file: ${resolvedPath}`); if (!containingRoot) {
throw new Error(
'file must be inside the browser session download directory or a CCS_BROWSER_UPLOAD_ROOTS entry'
);
} }
return resolvedPath; assertNoSensitiveLocalPathSegments(realFilePath, containingRoot, 'file');
const stat = fs.statSync(realFilePath);
if (!stat.isFile()) {
throw new Error(`file is not a regular file: ${realFilePath}`);
}
if (stat.size > MAX_LOCAL_TRANSFER_FILE_BYTES) {
throw new Error(
`file exceeds maximum size of ${MAX_LOCAL_TRANSFER_FILE_BYTES} bytes: ${realFilePath}`
);
}
return realFilePath;
}); });
} }
@@ -48,55 +48,59 @@ describe('ccs-browser MCP server - advanced interactions', () => {
}, },
]; ];
const responses = await runMcpRequests(pages, [ const responses = await runMcpRequests(
{ pages,
jsonrpc: '2.0', [
id: 901, {
method: 'tools/call', jsonrpc: '2.0',
params: { id: 901,
name: 'browser_drag_files', method: 'tools/call',
arguments: { selector: '#dropzone', files: [invoicePath, receiptPath] }, params: {
}, name: 'browser_drag_files',
}, arguments: { selector: '#dropzone', files: [invoicePath, receiptPath] },
{
jsonrpc: '2.0',
id: 902,
method: 'tools/call',
params: {
name: 'browser_drag_files',
arguments: {
selector: '#frame-dropzone',
files: [invoicePath],
frameSelector: '#upload-frame',
}, },
}, },
}, {
{ jsonrpc: '2.0',
jsonrpc: '2.0', id: 902,
id: 903, method: 'tools/call',
method: 'tools/call', params: {
params: { name: 'browser_drag_files',
name: 'browser_drag_files', arguments: {
arguments: { selector: '#frame-dropzone',
selector: '#shadow-dropzone', files: [invoicePath],
files: [receiptPath], frameSelector: '#upload-frame',
pierceShadow: true, },
}, },
}, },
}, {
{ jsonrpc: '2.0',
jsonrpc: '2.0', id: 903,
id: 904, method: 'tools/call',
method: 'tools/call', params: {
params: { name: 'browser_drag_files',
name: 'browser_drag_files', arguments: {
arguments: { selector: '#shadow-dropzone',
selector: '#cancel-dropzone', files: [receiptPath],
files: [invoicePath], pierceShadow: true,
},
}, },
}, },
}, {
]); jsonrpc: '2.0',
id: 904,
method: 'tools/call',
params: {
name: 'browser_drag_files',
arguments: {
selector: '#cancel-dropzone',
files: [invoicePath],
},
},
},
],
{ childEnv: { CCS_BROWSER_UPLOAD_ROOTS: tempDir } }
);
expect(getResponseText(responses.find((message) => message.id === 901))).toContain( expect(getResponseText(responses.find((message) => message.id === 901))).toContain(
'status: files-dropped' 'status: files-dropped'
@@ -184,7 +188,8 @@ describe('ccs-browser MCP server - advanced interactions', () => {
arguments: { selector: '#reject-dropzone', files: [okPath] }, arguments: { selector: '#reject-dropzone', files: [okPath] },
}, },
}, },
] ],
{ childEnv: { CCS_BROWSER_UPLOAD_ROOTS: tempDir } }
); );
expect(getResponseText(responses.find((message) => message.id === 904))).toContain( expect(getResponseText(responses.find((message) => message.id === 904))).toContain(
@@ -1,5 +1,14 @@
import { describe, expect, it } from 'bun:test'; import { describe, expect, it } from 'bun:test';
import { runMcpRequests, getResponseText, mkdtempSync, writeFileSync, tmpdir, join } from './browser-mcp-test-harness'; import { mkdirSync, realpathSync } from 'node:fs';
import { delimiter } from 'node:path';
import {
runMcpRequests,
getResponseText,
mkdtempSync,
writeFileSync,
tmpdir,
join,
} from './browser-mcp-test-harness';
import type { MockPageState } from './browser-mcp-test-harness'; import type { MockPageState } from './browser-mcp-test-harness';
describe('ccs-browser MCP server - downloads and file inputs', () => { describe('ccs-browser MCP server - downloads and file inputs', () => {
@@ -60,9 +69,15 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
{ responseTimeoutMs: 12000 } { responseTimeoutMs: 12000 }
); );
expect(getResponseText(responses.find((message) => message.id === 57))).toContain('scope: browser'); expect(getResponseText(responses.find((message) => message.id === 57))).toContain(
expect(getResponseText(responses.find((message) => message.id === 58))).toContain('suggestedFilename: report.csv'); 'scope: browser'
expect(getResponseText(responses.find((message) => message.id === 60))).toContain('status: canceled'); );
expect(getResponseText(responses.find((message) => message.id === 58))).toContain(
'suggestedFilename: report.csv'
);
expect(getResponseText(responses.find((message) => message.id === 60))).toContain(
'status: canceled'
);
expect(pages[0]?.browser?.setDownloadBehaviorCalls?.[0]?.behavior).toBe('allow'); expect(pages[0]?.browser?.setDownloadBehaviorCalls?.[0]?.behavior).toBe('allow');
expect(pages[0]?.browser?.canceledDownloadGuids).toContain('download-guid-1'); expect(pages[0]?.browser?.canceledDownloadGuids).toContain('download-guid-1');
}); });
@@ -93,6 +108,60 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
); );
}); });
it('restricts caller-provided download paths to configured safe roots', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-download-root-'));
const allowedPath = join(tempDir, 'reports');
const outsidePath = join(tmpdir(), 'ccs-browser-outside-downloads');
const sensitiveRoot = join(tempDir, '.aws');
const sensitiveDownloadPath = join(sensitiveRoot, 'reports');
const responses = await runMcpRequests(
[{ id: 'page-1', title: 'Reports', currentUrl: 'https://example.com/reports', browser: {} }],
[
{
jsonrpc: '2.0',
id: 615,
method: 'tools/call',
params: {
name: 'browser_set_download_behavior',
arguments: { behavior: 'accept', downloadPath: allowedPath },
},
},
{
jsonrpc: '2.0',
id: 616,
method: 'tools/call',
params: {
name: 'browser_set_download_behavior',
arguments: { behavior: 'accept', downloadPath: outsidePath },
},
},
{
jsonrpc: '2.0',
id: 619,
method: 'tools/call',
params: {
name: 'browser_set_download_behavior',
arguments: { behavior: 'accept', downloadPath: sensitiveDownloadPath },
},
},
],
{ childEnv: { CCS_BROWSER_DOWNLOAD_ROOTS: `${sensitiveRoot}${delimiter}${tempDir}` } }
);
expect(getResponseText(responses.find((message) => message.id === 615))).toContain(
`downloadPath: ${realpathSync(allowedPath)}`
);
const rejectedResponse = responses.find((message) => message.id === 616);
expect((rejectedResponse?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(rejectedResponse)).toContain(
'downloadPath must be inside the browser session download directory or a CCS_BROWSER_DOWNLOAD_ROOTS entry'
);
expect(getResponseText(responses.find((message) => message.id === 619))).toContain(
'downloadPath cannot include hidden or sensitive path segment: .aws'
);
});
it('rejects browser_cancel_download for completed downloads', async () => { it('rejects browser_cancel_download for completed downloads', async () => {
const pages: MockPageState[] = [ const pages: MockPageState[] = [
{ {
@@ -144,7 +213,9 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
{ responseTimeoutMs: 12000 } { responseTimeoutMs: 12000 }
); );
expect(getResponseText(responses.find((message) => message.id === 611))).toContain('status: completed'); expect(getResponseText(responses.find((message) => message.id === 611))).toContain(
'status: completed'
);
const response = responses.find((message) => message.id === 612); const response = responses.find((message) => message.id === 612);
expect((response?.result as { isError?: boolean }).isError).toBe(true); expect((response?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(response)).toContain( expect(getResponseText(response)).toContain(
@@ -188,7 +259,9 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
{ responseTimeoutMs: 12000 } { responseTimeoutMs: 12000 }
); );
expect(getResponseText(responses.find((message) => message.id === 62))).toContain('status: observed'); expect(getResponseText(responses.find((message) => message.id === 62))).toContain(
'status: observed'
);
}); });
it('sets files on selected-page, frameSelector, and pierceShadow file inputs', async () => { it('sets files on selected-page, frameSelector, and pierceShadow file inputs', async () => {
@@ -279,23 +352,30 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
}, },
}, },
}, },
] ],
{ childEnv: { CCS_BROWSER_UPLOAD_ROOTS: tempDir } }
); );
expect(getResponseText(responses.find((message) => message.id === 64))).toContain('pageIndex: 1'); expect(getResponseText(responses.find((message) => message.id === 64))).toContain(
expect(getResponseText(responses.find((message) => message.id === 65))).toContain('frameSelector: #upload-frame'); 'pageIndex: 1'
expect(getResponseText(responses.find((message) => message.id === 66))).toContain('pierceShadow: true'); );
expect(getResponseText(responses.find((message) => message.id === 65))).toContain(
'frameSelector: #upload-frame'
);
expect(getResponseText(responses.find((message) => message.id === 66))).toContain(
'pierceShadow: true'
);
expect((pages[1]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toEqual([ expect(
invoicePath, (pages[1]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles
receiptPath, ).toEqual([realpathSync(invoicePath), realpathSync(receiptPath)]);
]);
expect( expect(
(pages[0]?.frames?.[0]?.fileInputs?.['#frame-upload'] as MockFileInputState).assignedFiles (pages[0]?.frames?.[0]?.fileInputs?.['#frame-upload'] as MockFileInputState).assignedFiles
).toEqual([invoicePath]); ).toEqual([realpathSync(invoicePath)]);
expect( expect(
(pages[0]?.shadowRoots?.[0]?.fileInputs?.['#shadow-upload'] as MockFileInputState).assignedFiles (pages[0]?.shadowRoots?.[0]?.fileInputs?.['#shadow-upload'] as MockFileInputState)
).toEqual([receiptPath]); .assignedFiles
).toEqual([realpathSync(receiptPath)]);
}); });
it('uses pageId for browser_set_file_input when provided', async () => { it('uses pageId for browser_set_file_input when provided', async () => {
@@ -340,14 +420,85 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
arguments: { pageId: 'page-2', selector: '#pageid-upload', files: [assetPath] }, arguments: { pageId: 'page-2', selector: '#pageid-upload', files: [assetPath] },
}, },
}, },
] ],
{ childEnv: { CCS_BROWSER_UPLOAD_ROOTS: tempDir } }
); );
expect(getResponseText(responses.find((message) => message.id === 68))).toContain('pageIndex: 1'); expect(getResponseText(responses.find((message) => message.id === 68))).toContain(
'pageIndex: 1'
);
expect((pages[1]?.fileInputs?.['#pageid-upload'] as MockFileInputState).assignedFiles).toEqual([ expect((pages[1]?.fileInputs?.['#pageid-upload'] as MockFileInputState).assignedFiles).toEqual([
assetPath, realpathSync(assetPath),
]); ]);
expect((pages[0]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toBeUndefined(); expect(
(pages[0]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles
).toBeUndefined();
});
it('rejects file uploads outside configured roots and sensitive files inside configured roots', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-root-'));
const outsideDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-outside-'));
const outsidePath = join(outsideDir, 'secret.txt');
const sensitiveDir = join(tempDir, '.ssh');
const sensitivePath = join(sensitiveDir, 'id_rsa');
const sensitiveRootPath = join(sensitiveDir, 'public.txt');
writeFileSync(outsidePath, 'outside');
mkdirSync(sensitiveDir, { recursive: true });
writeFileSync(sensitivePath, 'private-key');
writeFileSync(sensitiveRootPath, 'not-a-key');
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Upload Guard',
currentUrl: 'https://example.com/',
fileInputs: {
'#real-file-input': { kind: 'file' },
},
},
],
[
{
jsonrpc: '2.0',
id: 617,
method: 'tools/call',
params: {
name: 'browser_set_file_input',
arguments: { selector: '#real-file-input', files: [outsidePath] },
},
},
{
jsonrpc: '2.0',
id: 618,
method: 'tools/call',
params: {
name: 'browser_set_file_input',
arguments: { selector: '#real-file-input', files: [sensitivePath] },
},
},
{
jsonrpc: '2.0',
id: 620,
method: 'tools/call',
params: {
name: 'browser_set_file_input',
arguments: { selector: '#real-file-input', files: [sensitiveRootPath] },
},
},
],
{ childEnv: { CCS_BROWSER_UPLOAD_ROOTS: `${sensitiveDir}${delimiter}${tempDir}` } }
);
expect(getResponseText(responses.find((message) => message.id === 617))).toContain(
'file must be inside the browser session download directory or a CCS_BROWSER_UPLOAD_ROOTS entry'
);
expect(getResponseText(responses.find((message) => message.id === 618))).toContain(
'file cannot include hidden or sensitive path segment: .ssh'
);
expect(getResponseText(responses.find((message) => message.id === 620))).toContain(
'file cannot include hidden or sensitive path segment: .ssh'
);
}); });
it('rejects browser_set_file_input when target is not a file input, local file is missing, or page selectors conflict', async () => { it('rejects browser_set_file_input when target is not a file input, local file is missing, or page selectors conflict', async () => {
@@ -393,10 +544,16 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
method: 'tools/call', method: 'tools/call',
params: { params: {
name: 'browser_set_file_input', name: 'browser_set_file_input',
arguments: { pageIndex: 0, pageId: 'page-1', selector: '#real-file-input', files: [okPath] }, arguments: {
pageIndex: 0,
pageId: 'page-1',
selector: '#real-file-input',
files: [okPath],
},
}, },
}, },
] ],
{ childEnv: { CCS_BROWSER_UPLOAD_ROOTS: tempDir } }
); );
expect(getResponseText(responses.find((message) => message.id === 69))).toContain( expect(getResponseText(responses.find((message) => message.id === 69))).toContain(
@@ -409,5 +566,4 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
'pageIndex and pageId cannot be used together' 'pageIndex and pageId cannot be used together'
); );
}); });
}); });