diff --git a/README.md b/README.md index a563f6c..983b3a6 100644 --- a/README.md +++ b/README.md @@ -226,15 +226,16 @@ Then point your MCP client at `http://localhost:8080/mcp` using the same header/ ### Self-hosted environment variables -| Variable | Required | Default | Description | -| ------------------------- | -------- | --------------------------------------- | -------------------------------------------------- | -| `BROWSERLESS_TOKEN` | Yes | — | Your Browserless API token | -| `BROWSERLESS_API_URL` | No | `https://production-sfo.browserless.io` | API endpoint (for self-hosted Browserless) | -| `TRANSPORT` | No | `stdio` | Transport type: `stdio` or `httpStream` | -| `PORT` | No | `8080` | HTTP server port (only for `httpStream` transport) | -| `BROWSERLESS_TIMEOUT` | No | `30000` | Request timeout in milliseconds | -| `BROWSERLESS_MAX_RETRIES` | No | `3` | Max retry attempts for failed requests | -| `BROWSERLESS_CACHE_TTL` | No | `60000` | Cache TTL in milliseconds (0 to disable) | +| Variable | Required | Default | Description | +| ------------------------- | -------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `BROWSERLESS_TOKEN` | Yes | — | Your Browserless API token | +| `BROWSERLESS_API_URL` | No | `https://production-sfo.browserless.io` | API endpoint (for self-hosted Browserless) | +| `TRANSPORT` | No | `stdio` | Transport type: `stdio` or `httpStream` | +| `PORT` | No | `8080` | HTTP server port (only for `httpStream` transport) | +| `BROWSERLESS_TIMEOUT` | No | `30000` | Request timeout in milliseconds | +| `BROWSERLESS_MAX_RETRIES` | No | `3` | Max retry attempts for failed requests | +| `BROWSERLESS_CACHE_TTL` | No | `60000` | Cache TTL in milliseconds (0 to disable) | +| `MCP_COMPLIANCE_MODE` | No | unset (full surface) | Serve the reduced, directory-compliant surface. Fails closed: any set value except `false`/`0`/`no`/`off` enables it | ## MCP Resources diff --git a/src/@types/types.d.ts b/src/@types/types.d.ts index 3d10f49..3c83374 100644 --- a/src/@types/types.d.ts +++ b/src/@types/types.d.ts @@ -66,6 +66,10 @@ export interface McpConfig { maxRetries: number; cacheTtlMs: number; analyticsEnabled: boolean; + // Required (not optional): the compliant surface is a security gate, so every + // McpConfig must choose explicitly — an omitted field must not default to the + // fuller/prohibited surface. + complianceMode: boolean; sqsQueueUrl?: string; sqsRegion: string; oauthEnabled: boolean; diff --git a/src/config.ts b/src/config.ts index 6ac6451..52de8de 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,6 +21,33 @@ const DEFAULT_ALLOWED_REDIRECT_URI_PATTERNS = [ 'https://eu1.make.celonis.com/oauth/cb/mcp', // Make.com Celonis-hosted (enterprise) — EU region ]; +// Fail closed on the compliance gate: any SET value that isn't an explicit +// opt-out enables the compliant (restricted) surface. A fumbled flag — "TRUE", +// "1", a trailing space, a typo, even an empty string — must not silently fall +// through to the full, prohibited surface on a directory-listed endpoint. Only +// an UNSET var or an explicit opt-out token (false/0/no/off) serves the full +// surface. +const COMPLIANCE_OPT_OUT = new Set(['false', '0', 'no', 'off']); +const COMPLIANCE_OPT_IN = new Set(['true', '1', 'yes', 'on']); +function parseComplianceMode(raw: string | undefined): boolean { + if (raw === undefined) return false; + return !COMPLIANCE_OPT_OUT.has(raw.trim().toLowerCase()); +} + +// Classify the raw MCP_COMPLIANCE_MODE for boot logging. `unrecognized` still +// resolves to compliant (fail-closed, see parseComplianceMode) but is surfaced +// as a warning so a typo'd / mis-scoped value ("ture", "compliant", a stray +// space) is visible instead of silently reading as an intentional opt-in. +export function classifyComplianceInput( + raw: string | undefined, +): 'unset' | 'opt-out' | 'opt-in' | 'unrecognized' { + if (raw === undefined) return 'unset'; + const v = raw.trim().toLowerCase(); + if (COMPLIANCE_OPT_OUT.has(v)) return 'opt-out'; + if (COMPLIANCE_OPT_IN.has(v)) return 'opt-in'; + return 'unrecognized'; +} + export function getConfig(): McpConfig { return { browserlessToken: process.env.BROWSERLESS_TOKEN, @@ -31,6 +58,10 @@ export function getConfig(): McpConfig { maxRetries: parseInt(process.env.BROWSERLESS_MAX_RETRIES ?? '3', 10), cacheTtlMs: parseInt(process.env.BROWSERLESS_CACHE_TTL ?? '60000', 10), analyticsEnabled: process.env.ANALYTICS_ENABLED === 'true', + // Per-process toggle for the compliant surface used by the OpenAI/Anthropic + // directory listings: registers fewer tools and de-fangs the agent (see + // tools/compliance.ts). Fails closed — see parseComplianceMode. + complianceMode: parseComplianceMode(process.env.MCP_COMPLIANCE_MODE), sqsQueueUrl: process.env.SQS_QUEUE_URL, sqsRegion: process.env.SQS_REGION ?? 'us-west-2', // OAuth (Supabase) diff --git a/src/index.ts b/src/index.ts index 50f0583..2283ce2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,23 +4,12 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { FastMCP, OAuthProvider } from 'fastmcp'; import { OAuthProxy } from 'fastmcp/auth'; -import { getConfig } from './config.js'; +import { getConfig, classifyComplianceInput } from './config.js'; import type { BrowserlessSession } from './@types/types.js'; -import { registerSmartScraperTool } from './tools/smartscraper.js'; -import { registerFunctionTool } from './tools/function.js'; -import { registerExportTool } from './tools/export.js'; -import { registerAgentTools } from './tools/agent.js'; -import { registerSearchTool } from './tools/search.js'; -import { registerMapTool } from './tools/map.js'; -import { registerCrawlTool } from './tools/crawl.js'; -import { registerPerformanceTool } from './tools/performance.js'; -import { registerApiDocsResource } from './resources/api-docs.js'; -import { registerStatusResource } from './resources/status.js'; +import { registerSurface } from './tools/register.js'; import { registerUploadRoute } from './resources/upload-route.js'; import { registerDownloadRoute } from './resources/download-route.js'; import { clearSession } from './lib/download-store.js'; -import { registerScrapeUrlPrompt } from './prompts/scrape-url.js'; -import { registerExtractContentPrompt } from './prompts/extract-content.js'; import { AnalyticsHelper } from './lib/analytics.js'; import { installSupabaseTokenTtlPatch } from './lib/account-resolver.js'; import { resolveBrowserlessAuth } from './lib/http-auth.js'; @@ -112,10 +101,12 @@ const hybridAuthenticate = authHeader: request.headers.authorization as string | undefined, tokenQuery: params.get('token') || undefined, apiUrlHeader: request.headers['x-browserless-api-url'] as - string | undefined, + | string + | undefined, browserlessUrlQuery: params.get('browserlessUrl') || undefined, sessionIdHeader: request.headers['x-browserless-session-id'] as - string | undefined, + | string + | undefined, sessionIdQuery: params.get('browserlessSessionId') || undefined, }, config, @@ -130,18 +121,29 @@ const server = new FastMCP({ authenticate: hybridAuthenticate, }); -registerSmartScraperTool(server, config, analytics); -registerFunctionTool(server, config, analytics); -registerExportTool(server, config, analytics); -registerAgentTools(server, config, analytics); -registerSearchTool(server, config, analytics); -registerMapTool(server, config, analytics); -registerCrawlTool(server, config, analytics); -registerPerformanceTool(server, config, analytics); -registerApiDocsResource(server, config); -registerStatusResource(server, config); -registerScrapeUrlPrompt(server); -registerExtractContentPrompt(server); +registerSurface(server, config, analytics); +// Log the active surface (both transports) so it's visible in the boot logs. +// complianceMode fails closed (see parseComplianceMode), so a fumbled *value* +// lands on the compliant surface. Distinguish "unset" (a dropped/wrong-scoped +// env var on a directory deploy) from a deliberate opt-out — both print "full" +// otherwise — and warn on an unrecognized value so a typo isn't read silently +// as an intentional opt-in. +const complianceInput = classifyComplianceInput( + process.env.MCP_COMPLIANCE_MODE, +); +if (complianceInput === 'unrecognized') { + console.error( + `[browserless-mcp] WARNING: MCP_COMPLIANCE_MODE="${process.env.MCP_COMPLIANCE_MODE}" ` + + 'is not a recognized value; defaulting to the compliant (reduced) surface. ' + + 'Set "true" for compliant or "false" for the full surface.', + ); +} +const complianceSurface = config.complianceMode + ? 'compliant (reduced)' + : complianceInput === 'unset' + ? 'full (MCP_COMPLIANCE_MODE unset — set it to "true" for the compliant surface)' + : 'full (explicit opt-out)'; +console.error(`[browserless-mcp] Tool surface: ${complianceSurface}`); server.on('connect', (event) => { const id = event.session.sessionId ?? 'stdio'; diff --git a/src/resources/status.ts b/src/resources/status.ts index d552902..80b7783 100644 --- a/src/resources/status.ts +++ b/src/resources/status.ts @@ -17,6 +17,7 @@ export function registerStatusResource( text: JSON.stringify( { apiUrl: config.browserlessApiUrl, + surface: config.complianceMode ? 'compliant' : 'full', ok: false, message: 'No BROWSERLESS_TOKEN configured. For HTTP: pass Authorization header.', @@ -33,8 +34,11 @@ export function registerStatusResource( return { text: JSON.stringify( { - apiUrl: config.browserlessApiUrl, + // Spread status first so apiUrl/surface stay authoritative even if + // the status payload ever grows an overlapping key. ...status, + apiUrl: config.browserlessApiUrl, + surface: config.complianceMode ? 'compliant' : 'full', timestamp: new Date().toISOString(), }, null, diff --git a/src/skills/cookie-consent.md b/src/skills/cookie-consent.md index 563e8d6..1ceea61 100644 --- a/src/skills/cookie-consent.md +++ b/src/skills/cookie-consent.md @@ -30,7 +30,9 @@ No match → fallback to attribute-based deep selectors: `< button[aria-label*=" ## Don't - Click `Accept all` reflexively. Sites track aggressively and may serve different content. Prefer reject when both present + - Dismiss via `evaluate` removing banner element. Consent state server-side/cookies; hiding banner doesn't grant access, leaves event handlers blocking clicks + - Continue with selectors from pre-dismiss snapshot. Always re-snapshot after close ## Batching diff --git a/src/skills/dynamic-content.md b/src/skills/dynamic-content.md index 5ec4738..e555155 100644 --- a/src/skills/dynamic-content.md +++ b/src/skills/dynamic-content.md @@ -67,6 +67,9 @@ ## Avoid + + - `evaluate` with setTimeout/Promise (returns before timer completes) + - Multiple `waitForTimeout` stacked (use specific wait methods) - Tight snapshot loop without wait (burns tokens, races page) diff --git a/src/skills/index.ts b/src/skills/index.ts index 8a161e6..09c1dba 100644 --- a/src/skills/index.ts +++ b/src/skills/index.ts @@ -266,15 +266,80 @@ export const markFired = ( } }; -export const renderSkill = (id: SkillId): string => { +// A skill body carries two surface-specific markers so one file serves both: +// full-only (evaluate +// techniques, captcha selectors) — dropped in compliant render. +// compliant-only — the +// replacement guidance for what the omit removed (e.g. `screenshot`/`html` +// instead of `evaluate`), so the reduced recipe stays coherent; dropped in +// full render. +// Each render drops the other surface's blocks and strips its own markers. +// compliance-mode.spec.ts asserts the compliant render of every allowlisted +// skill contains no evaluate/captcha content. +const COMPLIANT_OMIT_BLOCK = + /[\s\S]*?\n*/g; +const COMPLIANT_ONLY_BLOCK = + /[\s\S]*?\n*/g; +const MARKER_LINE = /[ \t]*\n?/g; + +// The block strippers only match EXACT, balanced markers. A malformed one — a +// typo, stray spacing, an unclosed/mismatched/nested pair — silently leaves the +// block in place (prohibited content retained in the compliant render) and the +// MARKER_LINE pass then erases the orphan marker, hiding the evidence. Validate +// at skill-load so any anomaly fails closed at boot rather than leaking later. +const VALID_MARKER = /^$/; +const SUSPECT_MARKER = //gi; +const MARKER_TOKEN = //g; + +export const validateMarkers = (body: string, path: string): void => { + // Any comment mentioning "compliant" must be an exact marker — catches typos + // and stray spacing the strippers would silently skip over. + for (const m of body.match(SUSPECT_MARKER) ?? []) { + if (!VALID_MARKER.test(m)) { + throw new Error( + `skill ${path}: malformed compliant marker ${JSON.stringify(m)}`, + ); + } + } + // Markers must be balanced, matched by kind, and never nested. + const open: string[] = []; + for (const [, slash, kind] of body.matchAll(MARKER_TOKEN)) { + if (slash === '') { + if (open.length) { + throw new Error(`skill ${path}: nested compliant-${kind} marker`); + } + open.push(kind); + } else if (open.pop() !== kind) { + throw new Error(`skill ${path}: unbalanced compliant-${kind} close`); + } + } + if (open.length) { + throw new Error(`skill ${path}: unclosed compliant-${open[0]} marker`); + } +}; + +// Fail closed at boot rather than leak a mis-marked block per render. +skills.forEach((skill) => validateMarkers(skill.body, skill.path)); + +export const renderSkill = (id: SkillId, compliant: boolean): string => { const skill = skills.find((s) => s.id === id); if (!skill) return ''; + const body = skill.body + .replace(compliant ? COMPLIANT_OMIT_BLOCK : COMPLIANT_ONLY_BLOCK, '') + .replace(MARKER_LINE, '') + .trimEnd(); return [ `--- SKILL: ${skill.id} (${skill.path}) ---`, - skill.body.trimEnd(), + body, '--- END SKILL ---', ].join('\n'); }; -export const renderSkills = (ids: ReadonlyArray): string => - ids.map(renderSkill).filter(Boolean).join('\n\n'); +export const renderSkills = ( + ids: ReadonlyArray, + compliant: boolean, +): string => + ids + .map((id) => renderSkill(id, compliant)) + .filter(Boolean) + .join('\n\n'); diff --git a/src/skills/modals.md b/src/skills/modals.md index ff81a7e..63b7c58 100644 --- a/src/skills/modals.md +++ b/src/skills/modals.md @@ -17,6 +17,8 @@ Snapshot shows `role: dialog` or `role: alertdialog` — modal is open, traps fo { "method": "click", "params": { "selector": "[aria-label='Close']" } } ``` + + 3. **Escape key:** ```json @@ -28,6 +30,8 @@ Snapshot shows `role: dialog` or `role: alertdialog` — modal is open, traps fo } ``` + + 4. **Click backdrop:** ```json @@ -52,5 +56,8 @@ Critical confirmations ("Delete?"). Don't auto-dismiss. Find explicit button (`C ## Avoid + + - Removing modal DOM via evaluate (SPAs remount it) + - Interacting with page behind without closing first (pointer events captured) diff --git a/src/skills/screenshots.md b/src/skills/screenshots.md index 0a9e9d8..ec29b2e 100644 --- a/src/skills/screenshots.md +++ b/src/skills/screenshots.md @@ -65,7 +65,10 @@ when you don't need to view it. ## Avoid + + - OCR via evaluate (you have vision input) - Screenshotting for structured data (use snapshot/evaluate) + - Full-page screenshots by default (pick scope) - Multiple screenshots of same state (one is enough) diff --git a/src/skills/shadow-dom.md b/src/skills/shadow-dom.md index 4de70ed..735dd3a 100644 --- a/src/skills/shadow-dom.md +++ b/src/skills/shadow-dom.md @@ -27,6 +27,8 @@ When snapshot lists `deep-ref=< button#deny`, pass to `click` / `type` / `hover` { "method": "click", "params": { "selector": "< button#deny" } } ``` + + ## Constructing deep selectors for iframes snapshot didn't surface Fallback only — most cross-origin iframes are now in the snapshot (see above). Some widgets still have nothing meaningful in the accessibility tree. Build selector by hand: @@ -38,12 +40,22 @@ Fallback only — most cross-origin iframes are now in the snapshot (see above). URL pattern is glob — `*` matches any substring. + + ## What works and what doesn't Coordinate-based actions work through deep selectors: **`click`, `type`, `hover`, `checkbox`**. DOM-read actions **don't** work, fail or return null: **`text`, `html`, `waitForSelector`** with deep selectors. + + +To read content inside a frame or shadow root, use the snapshot — iframes are surfaced above with ready `deep-ref=` selectors — or `screenshot` the page and read it visually. Deep selectors are for interaction, not reading. + + + + + To read content from shadow root or iframe, use `evaluate` with explicit traversal: ```json @@ -66,6 +78,8 @@ For shadow DOM: } ``` + + ## Recovery when regular selector fails 1. Retry same selector with `< ` prefix (MCP suggests automatically) diff --git a/src/skills/snapshot-misses.md b/src/skills/snapshot-misses.md index d79e614..78f874f 100644 --- a/src/skills/snapshot-misses.md +++ b/src/skills/snapshot-misses.md @@ -17,6 +17,8 @@ Snapshot at element limit (truncated) or empty. What you need may not be in it. { "method": "snapshot", "params": { "maxElements": 1000 } } ``` + + 2. **If element has no accessible name**, use `evaluate` to read directly: ```json @@ -50,6 +52,24 @@ Snapshot at element limit (truncated) or empty. What you need may not be in it. } ``` + + + + +2. **If a control has no accessible name** (icon-only button, image without `alt`), `screenshot` to see it, then act via a nearby labeled element from the snapshot — or read a region with `html` / `text` on a container selector: + + ```json + { "method": "html", "params": { "selector": "main" } } + ``` + +3. **For image-rendered results** (WolframAlpha, LaTeX, charts, image search), `screenshot` and read the answer visually — the model has vision, so a single `` whose meaning isn't in the DOM is still readable: + + ```json + { "method": "screenshot" } + ``` + + + 4. **For very long lists**, scroll and re-snapshot rather than raising `maxElements` — snapshot pagination more reliable than one giant pull: ```json @@ -64,4 +84,6 @@ Snapshot at element limit (truncated) or empty. What you need may not be in it. ## Don't - Raise `maxElements` past ~2000 — model spends more on snapshot reading than task gains. Scroll and paginate instead + - `evaluate` to crawl `document.body.innerHTML` for general extraction. Snapshot structured; raw HTML floods context with markup. Use `evaluate` only for _specific_ attributes snapshot can't surface + diff --git a/src/tools/agent.ts b/src/tools/agent.ts index cc428d5..afbf280 100644 --- a/src/tools/agent.ts +++ b/src/tools/agent.ts @@ -27,10 +27,10 @@ import { classifyAgentError } from '../lib/error-classifier.js'; import { AnalyticsHelper } from '../lib/analytics.js'; import { defineTool } from '../lib/define-tool.js'; import { - detectSkills, markFired, renderSkill, renderSkills, + skillsRegistry, } from '../skills/index.js'; import { loadSiteSkill, @@ -38,6 +38,15 @@ import { siteRecipeNotice, } from '../skills/sites.js'; import { AgentParamsSchema } from './schemas.js'; +import { + isCompliant, + detectVisibleSkills, + COMPLIANT_SKILLS, + COMPLIANT_AGENT_DESCRIPTION, + COMPLIANT_SKILL_TOOL_DESCRIPTION, + COMPLIANT_AGENT_METHODS, + CompliantAgentParamsSchema, +} from './compliance.js'; import { AGENT_SYSTEM_PROMPT, SKILL_TOOL_DESCRIPTION, @@ -66,11 +75,32 @@ export { const SNAPSHOT_METHOD = 'snapshot'; const FATAL_CODES = new Set(['BROWSER_CRASHED']); -const appendSkills = (base: string, ids: ReadonlyArray): string => { +const appendSkills = ( + base: string, + ids: ReadonlyArray, + compliant: boolean, +): string => { if (ids.length === 0) return base; - return `${base}\n\n${renderSkills(ids)}`; + return `${base}\n\n${renderSkills(ids, compliant)}`; }; +// Surface-specific reply extras appended to an agent response: auto-injected +// skill bodies (de-fanged on the compliant surface) and the site-recipe pointer +// (suppressed on compliant — recipes can prescribe proxy/evaluate/login steps). +// Pure + exported so both branches' threading of `compliant` is regression- +// tested without a live session (compliance-mode.spec.ts). Note: in full mode +// siteRecipeNotice mutates `sitesSurfaced` (dedup), matching the prior inline +// behavior; compliant short-circuits before that call. +export const buildSurfaceExtras = ( + compliant: boolean, + triggered: ReadonlyArray, + currentUrl: string | undefined, + sitesSurfaced: Set, +): { skills: string; siteNotice: string } => ({ + skills: triggered.length > 0 ? renderSkills(triggered, compliant) : '', + siteNotice: compliant ? '' : siteRecipeNotice(currentUrl, sitesSurfaced), +}); + const SCREENSHOT_MIME: Record = { jpeg: 'image/jpeg', webp: 'image/webp', @@ -387,15 +417,56 @@ const SkillToolParamsSchema = z 'Provide either `id` (load a skill) or `site` (list site recipes).', }); +// The agent tool is registered with either the full or the compliant params +// schema. Both are structural subtypes of this boundary type: `method`/`params` +// are the full-surface single-command passthrough (absent in compliant mode), +// so they are optional here and run() reads them undefined-safely. +type AgentToolParams = Omit & { + method?: string; + params?: Record; +}; + export function registerAgentTools( server: FastMCP, config: McpConfig, analytics?: AnalyticsHelper, ): void { + const compliant = isCompliant(config); + + // Compliant mode: drop the circumvention/autologin recipes from the enum so + // they're neither selectable nor advertised, and omit the `site` recipe + // lookup — arbitrary site recipes can prescribe proxy/evaluate/login steps + // that can't be de-fanged, so the compliant surface exposes only the vetted + // in-house skills. + const compliantSkillIds = skillsRegistry + .map((s) => s.id) + .filter((id) => COMPLIANT_SKILLS.has(id)); + // z.enum([]) doesn't throw — it builds an all-rejecting schema, so an empty + // allowlist would make browserless_skill silently reject every call. Fail + // loudly at boot instead. The destructure also gives z.enum a non-empty tuple + // type, dropping the cast. + if (compliantSkillIds.length === 0) { + throw new Error('Compliant surface has no allowlisted skills configured.'); + } + const [firstSkillId, ...restSkillIds] = compliantSkillIds; + const compliantSkillParamsSchema = z + .object({ + id: z + .enum([firstSkillId, ...restSkillIds]) + .describe( + 'The skill to load (see tool description for the full list).', + ), + }) + .strict(); + defineTool(server, config, analytics, { name: 'browserless_skill', - description: SKILL_TOOL_DESCRIPTION, - parameters: SkillToolParamsSchema, + description: compliant + ? COMPLIANT_SKILL_TOOL_DESCRIPTION + : SKILL_TOOL_DESCRIPTION, + parameters: compliant + ? (compliantSkillParamsSchema as z.ZodType) + : SkillToolParamsSchema, annotations: { title: 'Load Browserless Skill', readOnlyHint: true, @@ -404,10 +475,16 @@ export function registerAgentTools( }, run: async ({ params, analytics, token, apiUrl }) => { const id = params.id ?? ''; - const body = - params.site !== undefined + // Compliant: no `site` recipes; only allowlisted in-house skills. The enum + // already blocks other ids — this guard is defense-in-depth for a bypass. + if (compliant && !COMPLIANT_SKILLS.has(id as SkillId)) { + throw new UserError(`Skill "${id}" is not available on this endpoint.`); + } + const body = compliant + ? renderSkill(id as SkillId, true) + : params.site !== undefined ? renderSiteSkillList(params.site) - : renderSkill(id as SkillId) || loadSiteSkill(id) || ''; + : renderSkill(id as SkillId, false) || loadSiteSkill(id) || ''; analytics?.fireSkill(token, { ...buildSkillEventProps(params, body), api_url: apiUrl, @@ -428,12 +505,19 @@ export function registerAgentTools( }, }); - defineTool(server, config, analytics, { + defineTool(server, config, analytics, { name: 'browserless_agent', - description: - AGENT_SYSTEM_PROMPT + - fileTransferModeNote(config.transport, config.mcpBaseUrl), - parameters: AgentParamsSchema, + description: compliant + ? COMPLIANT_AGENT_DESCRIPTION + : AGENT_SYSTEM_PROMPT + + fileTransferModeNote(config.transport, config.mcpBaseUrl), + // Cast: Zod's generic is invariant, so the ternary needs it. AgentToolParams + // is a genuine supertype of both schemas' output (method/params optional); the + // runtime schema FastMCP validates against is the real guard, plus the + // compliance-mode spec (drift guard + run()-layer allowlist). + parameters: (compliant + ? CompliantAgentParamsSchema + : AgentParamsSchema) as z.ZodType, annotations: { title: 'Browserless Agent', readOnlyHint: false, @@ -458,7 +542,33 @@ export function registerAgentTools( method: c.method, params: c.params ?? {}, })) - : [{ method: params.method, params: params.params ?? {} }]; + : [{ method: params.method ?? '', params: params.params ?? {} }]; + + // Defense-in-depth: even if the schema were mis-built, the compliant + // surface never forwards a non-allowlisted method, an auth-profile, or a + // proxy argument to the backend. + if (compliant) { + for (const c of commands) { + if (!COMPLIANT_AGENT_METHODS.has(c.method)) { + throw new UserError( + `Command "${c.method}" is not available on this endpoint.`, + ); + } + } + if ( + params.profile !== undefined || + params.createProfile !== undefined + ) { + throw new UserError( + 'Authentication profiles are not available on this endpoint.', + ); + } + if (params.proxy !== undefined) { + throw new UserError( + 'Proxy configuration is not available on this endpoint.', + ); + } + } const proxy = params.proxy; const profile = params.profile; @@ -678,13 +788,14 @@ export function registerAgentTools( : undefined, }); - const triggered = detectSkills( + const triggered = detectVisibleSkills( { snapshot: err.snapshot, error: err, cmd, apiUrl }, agentSession.skillState, + compliant, ); markFired(agentSession.skillState, triggered); - throw new UserError(appendSkills(body, triggered)); + throw new UserError(appendSkills(body, triggered, compliant)); } // Capture the first URL we observe in the batch as a fallback @@ -720,7 +831,7 @@ export function registerAgentTools( ? (lastResult as unknown as SnapshotResult) : undefined; - const triggered = detectSkills( + const triggered = detectVisibleSkills( { snapshot: lastSnapshot, cmd: lastCmd, @@ -728,6 +839,7 @@ export function registerAgentTools( apiUrl, }, agentSession.skillState, + compliant, ); markFired(agentSession.skillState, triggered); @@ -752,12 +864,15 @@ export function registerAgentTools( } // Surface a site-recipe pointer for the URL this batch landed on — the - // tool-description prose gate gets skipped/clipped, so push it as a result. + // tool-description prose gate gets skipped/clipped, so push it as a + // result. Suppressed on the compliant surface (no site recipes there). const currentUrl = lastSnapshot?.url ?? (lastResult as { url?: string } | undefined)?.url ?? crossOriginBaseline; - const siteNotice = siteRecipeNotice( + const { skills: renderedSkills, siteNotice } = buildSurfaceExtras( + compliant, + triggered, currentUrl, agentSession.skillState.sitesSurfaced, ); @@ -776,10 +891,7 @@ export function registerAgentTools( // malformed URL — nothing to report } } - const extraText = [ - triggered.length > 0 ? renderSkills(triggered) : '', - siteNotice, - ] + const extraText = [renderedSkills, siteNotice] .filter(Boolean) .join('\n\n'); const appendExtra = (base: string): string => diff --git a/src/tools/compliance.ts b/src/tools/compliance.ts new file mode 100644 index 0000000..7f8466d --- /dev/null +++ b/src/tools/compliance.ts @@ -0,0 +1,89 @@ +import type { McpConfig, SkillId } from '../@types/types.js'; +import { detectSkills } from '../skills/index.js'; + +// The compliant (directory-listable) surface. Serves the OpenAI + Anthropic app +// directories, whose policies reject the full surface (CAPTCHA solving, +// arbitrary code, residential/geo proxy, stealth, autologin, mass collection). +// Enabled per-process by MCP_COMPLIANCE_MODE; when off, the same process serves +// the full surface. This module owns the compliance policy: the `isCompliant` +// predicate, the COMPLIANT_SKILLS allowlist + `visibleSkills` filter, the +// compliant descriptions, and a re-export of the agent schema + allowed methods +// (built in schemas.js, where the command schemas live). Tools import the policy +// from here; the per-tool derivations (search/export `.pick` allowlists, the +// register.ts surface gate) live in their own modules and branch on `isCompliant`. +export { + CompliantAgentParamsSchema, + COMPLIANT_AGENT_METHODS, +} from './schemas.js'; + +export const isCompliant = (config: McpConfig): boolean => + config.complianceMode === true; + +// ALLOWLIST of skills served on the compliant surface — fail-closed, mirroring +// the search/export `.pick` allowlists: a skill newly added to the registry does +// NOT auto-appear here; it must be added deliberately. Excludes captchas +// (circumvention) and autonomous-login/auth-profile (credential-driven login). +// Typed SkillId so a rename/removal of any listed id breaks the build. +export const COMPLIANT_SKILLS: ReadonlySet = new Set([ + 'shadow-dom', + 'cookie-consent', + 'modals', + 'snapshot-misses', + 'dynamic-content', + 'screenshots', + 'tabs', + 'file-transfers', +]); + +// Filter detected skill ids for the auto-injection path (see detectVisibleSkills +// in agent.ts): in compliant mode only allowlisted skills survive, so a +// restricted — or newly-added, not-yet-vetted — recipe never auto-injects into a +// reply. The skill enum is filtered off the same allowlist in agent.ts. +export const visibleSkills = ( + ids: ReadonlyArray, + compliant: boolean, +): SkillId[] => + compliant ? ids.filter((id) => COMPLIANT_SKILLS.has(id)) : ids.slice(); + +// Compose detectSkills + visibleSkills for the agent auto-injection path. Lives +// here (not in agent.ts) so agent.ts imports only this filtered wrapper, not the +// raw detectSkills — an agent.ts auto-inject site can't silently bypass the +// compliant filter (a revert to detectSkills won't resolve there). detectSkills +// stays exported from skills/index.ts for its other callers. +export const detectVisibleSkills = ( + ctx: Parameters[0], + state: Parameters[1], + compliant: boolean, +): SkillId[] => visibleSkills(detectSkills(ctx, state), compliant); + +export const COMPLIANT_AGENT_DESCRIPTION = + 'Drive a browser to complete a user-directed task on a page the user specifies: ' + + 'navigate, read, click, type, scroll, switch tabs, and screenshot. Use only for ' + + 'content the user is authorized to access. Do not use to bypass access controls ' + + 'or bot protection, solve CAPTCHAs, evade detection, route around IP/geo ' + + "restrictions, or access content in violation of a site's terms of service. " + + 'Provide `commands` as a sequential batch; only the final result is returned.'; + +export const COMPLIANT_SEARCH_DESCRIPTION = + 'Search the web using Browserless. Performs web searches via SearXNG ' + + 'and returns results from web, news, or images. Useful for research, ' + + 'gathering information, and finding relevant web pages.'; + +export const COMPLIANT_EXPORT_DESCRIPTION = + 'Export a webpage from a URL via the Browserless /export API. ' + + 'Fetches the URL and returns its content in the native format ' + + '(HTML, PDF, image, etc.). Automatically detects the content type.'; + +export const COMPLIANT_SKILL_TOOL_DESCRIPTION = `Load a Browserless agent skill on demand. + +Use this when you suspect the page exhibits a non-trivial mechanic but no SKILL block was auto-injected into a previous response. The auto-injection heuristics are conservative; calling this tool is the explicit fallback. + +Available skills: +- **shadow-dom** — deep selectors, iframe URL-pattern syntax, what works through deep-ref +- **cookie-consent** — vendor-specific dismiss recipes (OneTrust, Cookiebot, Didomi, etc.) +- **modals** — close-button heuristics, ESC handling, alertdialog vs. dialog +- **snapshot-misses** — truncated/empty snapshots, image-rendered content +- **dynamic-content** — choosing the right \`wait*\` method after async triggers +- **screenshots** — when to screenshot vs. snapshot, scope and format choices +- **tabs** — multi-tab workflows, peek-without-switching +- **file-transfers** — \`uploadFile\` / \`getDownloads\`, stdio-path vs. base64 content, size caps`; diff --git a/src/tools/export.ts b/src/tools/export.ts index 2ddb28a..c0c9997 100644 --- a/src/tools/export.ts +++ b/src/tools/export.ts @@ -3,6 +3,7 @@ import type { Content } from 'fastmcp'; import { z } from 'zod'; import { defineTool, validateHttpUrl } from '../lib/define-tool.js'; import { profileField } from './schemas.js'; +import { isCompliant, COMPLIANT_EXPORT_DESCRIPTION } from './compliance.js'; import { AnalyticsHelper } from '../lib/analytics.js'; import type { ExportParams, @@ -61,20 +62,44 @@ export const ExportParamsSchema = z.object({ profile: profileField('before the page is exported'), }); +// Compliant surface: an explicit param ALLOWLIST (`.pick`, not `.omit`), so a +// field added to the full schema later does NOT auto-appear here — it stays off +// until deliberately allowed, matching the fail-closed philosophy. Notably +// excludes `includeResources` (ZIP every linked CSS/JS/image reads as bulk +// collection). `.strict()` rejects any non-allowed key loudly instead of +// silently stripping it (see ./compliance.ts). +// Excludes `profile` (auth-session hydration) for the same reason the compliant +// agent does — no authentication-profile capability on the compliant surface. +const CompliantExportParamsSchema = ExportParamsSchema.pick({ + url: true, + gotoOptions: true, + bestAttempt: true, + waitForTimeout: true, + timeout: true, +}).strict(); + export function registerExportTool( server: FastMCP, config: McpConfig, analytics?: AnalyticsHelper, ): void { + const compliant = isCompliant(config); + defineTool(server, config, analytics, { name: 'browserless_export', - description: - 'Export a webpage from a URL via the Browserless /export API. ' + - 'Fetches the URL and returns its content in the native format ' + - '(HTML, PDF, image, etc.). Automatically detects the content type. ' + - 'Set includeResources=true to bundle all page assets (CSS, JS, images) ' + - 'into a ZIP archive for offline use.', - parameters: ExportParamsSchema, + description: compliant + ? COMPLIANT_EXPORT_DESCRIPTION + : 'Export a webpage from a URL via the Browserless /export API. ' + + 'Fetches the URL and returns its content in the native format ' + + '(HTML, PDF, image, etc.). Automatically detects the content type. ' + + 'Set includeResources=true to bundle all page assets (CSS, JS, images) ' + + 'into a ZIP archive for offline use.', + // Cast: Zod's generic is invariant, so the ternary needs it. The compliant + // schema is a `.pick` subset (a structural subtype); the runtime schema + // FastMCP validates against is the real guard, plus the compliance-mode spec. + parameters: (compliant + ? CompliantExportParamsSchema + : ExportParamsSchema) as z.ZodType, annotations: { title: 'Browserless Export', readOnlyHint: true, @@ -88,6 +113,19 @@ export function registerExportTool( `live session first, or omit the profile parameter to export ` + `the page anonymously.`, run: async ({ client, params, log }) => { + // Defense-in-depth (parity with the agent allowlist): the `.pick().strict()` + // schema already rejects `includeResources`, but guard run() too so a future + // schema regression can't forward bulk asset capture to the backend. + if (compliant && params.includeResources !== undefined) { + throw new UserError( + 'includeResources is not available on this endpoint.', + ); + } + if (compliant && params.profile !== undefined) { + throw new UserError( + 'Authentication profiles are not available on this endpoint.', + ); + } const response = await client.exportPage({ url: params.url, gotoOptions: params.gotoOptions, diff --git a/src/tools/performance.ts b/src/tools/performance.ts index cc22f44..81f9bdf 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -1,8 +1,9 @@ -import { FastMCP } from 'fastmcp'; +import { FastMCP, UserError } from 'fastmcp'; import type { Content } from 'fastmcp'; import { z } from 'zod'; import { defineTool, validateHttpUrl } from '../lib/define-tool.js'; import { profileField } from './schemas.js'; +import { isCompliant } from './compliance.js'; import { AnalyticsHelper } from '../lib/analytics.js'; import type { McpConfig, @@ -43,11 +44,21 @@ export const PerformanceParamsSchema = z.object({ profile: profileField('before the Lighthouse audit runs'), }); +// Excludes `profile` (auth-session hydration) — no authentication-profile +// capability on the compliant surface (parity with agent/export/search). +const CompliantPerformanceParamsSchema = PerformanceParamsSchema.pick({ + url: true, + categories: true, + budgets: true, + timeout: true, +}).strict(); + export function registerPerformanceTool( server: FastMCP, config: McpConfig, analytics?: AnalyticsHelper, ): void { + const compliant = isCompliant(config); defineTool( server, config, @@ -59,7 +70,9 @@ export function registerPerformanceTool( 'Returns scores and metrics for accessibility, best practices, performance, PWA, and SEO. ' + 'Optionally filter by category or supply performance budgets. ' + 'Note: audits can take 30s–120s depending on the site.', - parameters: PerformanceParamsSchema, + parameters: compliant + ? (CompliantPerformanceParamsSchema as z.ZodType) + : PerformanceParamsSchema, annotations: { title: 'Browserless Lighthouse Performance Audit', readOnlyHint: true, @@ -73,6 +86,11 @@ export function registerPerformanceTool( `live session first, or omit the profile parameter to audit ` + `the page anonymously.`, run: async ({ client, params, log }) => { + if (compliant && params.profile !== undefined) { + throw new UserError( + 'Authentication profiles are not available on this endpoint.', + ); + } const response = await client.performance({ url: params.url, categories: params.categories, diff --git a/src/tools/register.ts b/src/tools/register.ts new file mode 100644 index 0000000..a353268 --- /dev/null +++ b/src/tools/register.ts @@ -0,0 +1,94 @@ +import { FastMCP } from 'fastmcp'; +import type { McpConfig } from '../@types/types.js'; +import { AnalyticsHelper } from '../lib/analytics.js'; +import { registerSmartScraperTool } from './smartscraper.js'; +import { registerExportTool } from './export.js'; +import { registerAgentTools } from './agent.js'; +import { registerSearchTool } from './search.js'; +import { registerPerformanceTool } from './performance.js'; +import { registerFunctionTool } from './function.js'; +import { registerMapTool } from './map.js'; +import { registerCrawlTool } from './crawl.js'; +import { isCompliant } from './compliance.js'; +import { registerApiDocsResource } from '../resources/api-docs.js'; +import { registerStatusResource } from '../resources/status.js'; +import { registerScrapeUrlPrompt } from '../prompts/scrape-url.js'; +import { registerExtractContentPrompt } from '../prompts/extract-content.js'; + +// Registers the advertised MCP surface — tools, resources, and prompts — so the +// compliance gate covers everything a directory reviewer can list, not just +// tools. Compliant mode omits smartscraper/function/map/crawl and their +// smartscraper-specific api-docs resource + scrape-url/extract-content prompts: +// the api-docs resource documents the prohibited proxy/captcha strategies, and +// the prompts instruct the model to call browserless_smartscraper (gated out). +// index.ts and the surface-guard spec both call this, so the test exercises the +// real gating. +// Every registrable surface item carries an explicit `surface` classification — +// there is no positional "unconditional" section a newly added tool could fall +// into and ship on the directory listing by accident. `'both'` = full + +// compliant; `'full'` = full only (dropped from the compliant surface). A new +// entry must state its surface, and the exact-set spec guards the compliant list +// against drift. `browserless_agent`/`browserless_skill` (both `'both'`) reduce +// their OWN shape via isCompliant internally. +type Surface = 'both' | 'full'; + +export function registerSurface( + server: FastMCP, + config: McpConfig, + analytics?: AnalyticsHelper, +): void { + const registrations: ReadonlyArray<{ + surface: Surface; + register: () => void; + }> = [ + { + surface: 'both', + register: () => registerExportTool(server, config, analytics), + }, + // agent + skill tools + { + surface: 'both', + register: () => registerAgentTools(server, config, analytics), + }, + { + surface: 'both', + register: () => registerSearchTool(server, config, analytics), + }, + { + surface: 'both', + register: () => registerPerformanceTool(server, config, analytics), + }, + // Generic service status — safe on both (it reports the active surface). + { surface: 'both', register: () => registerStatusResource(server, config) }, + // Full-surface only: scraping/code tools, plus the api-docs resource + // (documents proxy/captcha strategies) and the two prompts (instruct the + // model to call browserless_smartscraper). + { + surface: 'full', + register: () => registerSmartScraperTool(server, config, analytics), + }, + { + surface: 'full', + register: () => registerFunctionTool(server, config, analytics), + }, + { + surface: 'full', + register: () => registerMapTool(server, config, analytics), + }, + { + surface: 'full', + register: () => registerCrawlTool(server, config, analytics), + }, + { + surface: 'full', + register: () => registerApiDocsResource(server, config), + }, + { surface: 'full', register: () => registerScrapeUrlPrompt(server) }, + { surface: 'full', register: () => registerExtractContentPrompt(server) }, + ]; + + const compliant = isCompliant(config); + for (const { surface, register } of registrations) { + if (surface === 'both' || !compliant) register(); + } +} diff --git a/src/tools/schemas.ts b/src/tools/schemas.ts index bb7de62..554cbcd 100644 --- a/src/tools/schemas.ts +++ b/src/tools/schemas.ts @@ -740,6 +740,75 @@ export const AgentParamsSchema = z 'one) cannot both be set', }); +// ── Compliant surface variant (see ./compliance.ts) ────────────────────────── +// De-fanged agent surface for the OpenAI / Anthropic directory listings. Drops +// the commands + config those directories prohibit (CAPTCHA solving, arbitrary +// JS, residential/geo proxy, stealth, autologin) and the raw-BQL passthrough +// that could otherwise reach them by name. Server-side Zod is the enforcement, +// not the description; `.strict()` rejects (not silently strips) any removed +// top-level key (proxy / profile / createProfile / method / params) — `profile` +// hydrates a saved auth session, the most security-relevant dropped key. +const compliantCommandSchemas = [ + GotoCommandSchema, + BackCommandSchema, + ForwardCommandSchema, + ReloadCommandSchema, + SnapshotCommandSchema, + GetTabsCommandSchema, + SwitchTabCommandSchema, + CreateTabCommandSchema, + CloseTabCommandSchema, + ClickCommandSchema, + TypeCommandSchema, + SelectCommandSchema, + CheckboxCommandSchema, + HoverCommandSchema, + ScrollCommandSchema, + TextCommandSchema, + HtmlCommandSchema, + WaitForSelectorCommandSchema, + WaitForNavigationCommandSchema, + WaitForTimeoutCommandSchema, + WaitForRequestCommandSchema, + WaitForResponseCommandSchema, + LiveURLCommandSchema, + ScreenshotCommandSchema, + UploadFileCommandSchema, + GetDownloadsCommandSchema, + CloseCommandSchema, +] as const; + +/** Method names the compliant agent permits — defense-in-depth for run(). */ +export const COMPLIANT_AGENT_METHODS: ReadonlySet = new Set( + compliantCommandSchemas.map((s) => s.shape.method.value), +); + +// No `profile` / `createProfile`: the compliant surface exposes zero +// authentication-profile capability. `profile` hydrates a saved session's +// cookies/localStorage/IndexedDB (see profileField) — an auth-session-injection +// knob that belongs with the autonomous-login/auth-profile skills the compliant +// surface hides. `.strict()` rejects both if a caller sends them anyway. +export const CompliantAgentParamsSchema = z + .object({ + commands: z + .array(z.discriminatedUnion('method', compliantCommandSchemas)) + .min(1) + .describe( + 'Batch of browser navigation, read, and interaction commands ' + + '(click, type, scroll, etc.) executed sequentially against the page ' + + 'the user specifies. Only the final result is returned.', + ), + rationale: z + .string() + .optional() + .describe( + 'Short user-facing reason for this call (<=50 chars, present-continuous).', + ), + }) + .strict(); + +export type CompliantAgentParams = z.infer; + /** A single validated agent command. */ export type AgentCommand = z.infer; /** The full `browserless_agent` tool params (single command, batch, proxy, profile). */ diff --git a/src/tools/search.ts b/src/tools/search.ts index e1c7b21..d68107d 100644 --- a/src/tools/search.ts +++ b/src/tools/search.ts @@ -2,6 +2,7 @@ import { FastMCP, UserError } from 'fastmcp'; import type { Content } from 'fastmcp'; import { z } from 'zod'; import { defineTool } from '../lib/define-tool.js'; +import { isCompliant, COMPLIANT_SEARCH_DESCRIPTION } from './compliance.js'; import { AnalyticsHelper } from '../lib/analytics.js'; import type { McpConfig, @@ -80,19 +81,45 @@ export const SearchParamsSchema = z.object({ .describe('Request timeout in milliseconds'), }); +// Compliant surface: an explicit param ALLOWLIST (`.pick`, not `.omit`), so a +// field added to the full schema later does NOT auto-appear here — it stays off +// until deliberately allowed, matching the fail-closed philosophy. Notably +// excludes `scrapeOptions` (per-result scraping reads as a search-driven bulk +// relay). `.strict()` rejects any non-allowed key loudly instead of silently +// stripping it (see ./compliance.ts). +const CompliantSearchParamsSchema = SearchParamsSchema.pick({ + query: true, + limit: true, + lang: true, + country: true, + location: true, + tbs: true, + sources: true, + categories: true, + timeout: true, +}).strict(); + export function registerSearchTool( server: FastMCP, config: McpConfig, analytics?: AnalyticsHelper, ): void { + const compliant = isCompliant(config); + defineTool(server, config, analytics, { name: 'browserless_search', - description: - 'Search the web using Browserless and optionally scrape each result. ' + - 'Performs web searches via SearXNG and can return results from web, news, or images. ' + - 'Optionally scrape each result URL to get markdown, HTML, links, or screenshots. ' + - 'Useful for research, gathering information, and finding relevant web pages.', - parameters: SearchParamsSchema, + description: compliant + ? COMPLIANT_SEARCH_DESCRIPTION + : 'Search the web using Browserless and optionally scrape each result. ' + + 'Performs web searches via SearXNG and can return results from web, news, or images. ' + + 'Optionally scrape each result URL to get markdown, HTML, links, or screenshots. ' + + 'Useful for research, gathering information, and finding relevant web pages.', + // Cast: Zod's generic is invariant, so the ternary needs it. The compliant + // schema is a `.pick` subset (a structural subtype); the runtime schema + // FastMCP validates against is the real guard, plus the compliance-mode spec. + parameters: (compliant + ? CompliantSearchParamsSchema + : SearchParamsSchema) as z.ZodType, annotations: { title: 'Browserless Search', readOnlyHint: true, @@ -100,6 +127,12 @@ export function registerSearchTool( openWorldHint: true, }, run: async ({ client, params, log }) => { + // Defense-in-depth (parity with the agent allowlist): the `.pick().strict()` + // schema already rejects `scrapeOptions`, but guard run() too so a future + // schema regression can't forward per-result scraping to the backend. + if (compliant && params.scrapeOptions !== undefined) { + throw new UserError('scrapeOptions is not available on this endpoint.'); + } const response = await client.search({ query: params.query, limit: params.limit, diff --git a/test/integration/server.spec.ts b/test/integration/server.spec.ts index 3362728..519aaaa 100644 --- a/test/integration/server.spec.ts +++ b/test/integration/server.spec.ts @@ -17,6 +17,7 @@ const mockConfig: McpConfig = { maxRetries: 0, cacheTtlMs: 0, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '', diff --git a/test/lib/api-client.spec.ts b/test/lib/api-client.spec.ts index 031e7d7..1113604 100644 --- a/test/lib/api-client.spec.ts +++ b/test/lib/api-client.spec.ts @@ -16,6 +16,7 @@ const mockConfig: McpConfig & { browserlessToken: string } = { maxRetries: 0, cacheTtlMs: 60000, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '', diff --git a/test/lib/config.spec.ts b/test/lib/config.spec.ts index 09132ae..115cfb8 100644 --- a/test/lib/config.spec.ts +++ b/test/lib/config.spec.ts @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import { getConfig } from '../../src/config.js'; +import { getConfig, classifyComplianceInput } from '../../src/config.js'; const ENV_KEY = 'OAUTH_ADDITIONAL_REDIRECT_URI_PATTERNS'; const BASELINE_PATTERNS = [ @@ -66,3 +66,59 @@ describe('config.oauthAllowedRedirectUriPatterns', () => { expect(patterns).to.have.lengthOf(BASELINE_PATTERNS.length); }); }); + +describe('config.complianceMode', () => { + const COMPLIANCE_KEY = 'MCP_COMPLIANCE_MODE'; + let original: string | undefined; + + beforeEach(() => { + original = process.env[COMPLIANCE_KEY]; + delete process.env[COMPLIANCE_KEY]; + }); + + afterEach(() => { + if (original === undefined) delete process.env[COMPLIANCE_KEY]; + else process.env[COMPLIANCE_KEY] = original; + }); + + it('defaults to false (full surface) when the env var is unset', () => { + expect(getConfig().complianceMode).to.equal(false); + }); + + it('is true for "true"', () => { + process.env[COMPLIANCE_KEY] = 'true'; + expect(getConfig().complianceMode).to.equal(true); + }); + + it('fails closed: any set value that is not an explicit opt-out enables compliant mode', () => { + // '' (set-but-empty) is included: a set var, even empty, must not fall + // through to the full surface. + for (const v of ['1', 'yes', 'TRUE', 'on', ' true ', 'garbage', '']) { + process.env[COMPLIANCE_KEY] = v; + expect(getConfig().complianceMode, `value ${JSON.stringify(v)}`).to.equal( + true, + ); + } + }); + + it('serves the full surface only for unset or an explicit opt-out token', () => { + for (const v of ['false', '0', 'no', 'off', 'FALSE', ' off ']) { + process.env[COMPLIANCE_KEY] = v; + expect(getConfig().complianceMode, `value ${JSON.stringify(v)}`).to.equal( + false, + ); + } + }); + + it('classifyComplianceInput distinguishes unset / opt-out / opt-in / unrecognized', () => { + expect(classifyComplianceInput(undefined)).to.equal('unset'); + for (const v of ['false', '0', 'no', 'off', 'FALSE', ' off ']) + expect(classifyComplianceInput(v), v).to.equal('opt-out'); + for (const v of ['true', '1', 'yes', 'on', 'TRUE', ' true ']) + expect(classifyComplianceInput(v), v).to.equal('opt-in'); + // Fumbled values still parse to compliant (fail-closed) but classify as + // unrecognized so the boot log warns instead of reading them as opt-in. + for (const v of ['ture', 'compliant', 'garbage', '']) + expect(classifyComplianceInput(v), v).to.equal('unrecognized'); + }); +}); diff --git a/test/resources/resources.spec.ts b/test/resources/resources.spec.ts index 397d336..594cf6e 100644 --- a/test/resources/resources.spec.ts +++ b/test/resources/resources.spec.ts @@ -14,6 +14,7 @@ const mockConfig: McpConfig = { maxRetries: 0, cacheTtlMs: 0, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '', diff --git a/test/skills/skills.spec.ts b/test/skills/skills.spec.ts index 6bef5fe..7e1502f 100644 --- a/test/skills/skills.spec.ts +++ b/test/skills/skills.spec.ts @@ -61,7 +61,7 @@ describe('skills/registry', () => { }); it('renderSkill wraps body with markers and the file path', () => { - const out = renderSkill('shadow-dom'); + const out = renderSkill('shadow-dom', false); expect(out).to.match( /^--- SKILL: shadow-dom \(src\/skills\/shadow-dom\.md\) ---/, ); @@ -69,7 +69,7 @@ describe('skills/registry', () => { }); it('renderSkills joins multiple', () => { - const out = renderSkills(['shadow-dom', 'modals']); + const out = renderSkills(['shadow-dom', 'modals'], false); expect(out).to.include('SKILL: shadow-dom'); expect(out).to.include('SKILL: modals'); }); diff --git a/test/tools/agent.spec.ts b/test/tools/agent.spec.ts index c643d29..9eb389f 100644 --- a/test/tools/agent.spec.ts +++ b/test/tools/agent.spec.ts @@ -40,6 +40,7 @@ const mockConfig: McpConfig = { maxRetries: 0, cacheTtlMs: 0, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '', diff --git a/test/tools/annotations.spec.ts b/test/tools/annotations.spec.ts index a921cd1..5c6118c 100644 --- a/test/tools/annotations.spec.ts +++ b/test/tools/annotations.spec.ts @@ -20,6 +20,7 @@ const mockConfig: McpConfig = { maxRetries: 0, cacheTtlMs: 0, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '', diff --git a/test/tools/compliance-mode.spec.ts b/test/tools/compliance-mode.spec.ts new file mode 100644 index 0000000..1bc5662 --- /dev/null +++ b/test/tools/compliance-mode.spec.ts @@ -0,0 +1,898 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { FastMCP } from 'fastmcp'; +import { registerSurface } from '../../src/tools/register.js'; +import { + visibleSkills, + detectVisibleSkills, + COMPLIANT_SKILLS, + COMPLIANT_AGENT_METHODS, +} from '../../src/tools/compliance.js'; +import { + createSkillState, + skillsRegistry, + renderSkill, + validateMarkers, +} from '../../src/skills/index.js'; +import { buildSurfaceExtras } from '../../src/tools/agent.js'; +import type { McpConfig, SkillId } from '../../src/@types/types.js'; + +const baseConfig: McpConfig = { + browserlessToken: 'test-token', + browserlessApiUrl: 'https://api.example.com', + transport: 'stdio', + port: 8080, + requestTimeout: 30000, + maxRetries: 0, + cacheTtlMs: 0, + analyticsEnabled: false, + complianceMode: false, + sqsRegion: 'us-east-1', + oauthEnabled: false, + supabaseUrl: '', + supabaseOAuthClientId: '', + supabaseOAuthClientSecret: '', + supabaseServiceRoleKey: '', + mcpBaseUrl: '', + oauthAllowedRedirectUriPatterns: [], +}; + +type CapturedTool = { + name: string; + description: string; + parameters: { safeParse: (v: unknown) => { success: boolean; data?: any } }; +}; + +// Drive the REAL registration seam (src/tools/register.ts, which index.ts also +// calls) so this guard catches drift — e.g. a new circumvention tool registered +// unconditionally would change the compliant tool set and fail here. +function captureTools(complianceMode: boolean) { + const server = new FastMCP({ name: 'test', version: '0.1.0' }); + const toolSpy = sinon.spy(server, 'addTool'); + const promptSpy = sinon.spy(server, 'addPrompt'); + const resourceSpy = sinon.spy(server, 'addResource'); + const config: McpConfig = { ...baseConfig, complianceMode }; + + registerSurface(server, config); + + const calls = toolSpy + .getCalls() + .map((c) => c.args[0] as unknown as CapturedTool); + return { + names: calls.map((t) => t.name).sort(), + byName: new Map(calls.map((t) => [t.name, t])), + promptNames: promptSpy + .getCalls() + .map((c) => (c.args[0] as { name: string }).name) + .sort(), + resourceUris: resourceSpy + .getCalls() + .map((c) => (c.args[0] as { uri: string }).uri) + .sort(), + }; +} + +const VALID_GOTO = { method: 'goto', params: { url: 'https://example.com' } }; + +describe('compliance mode — compliant tool surface', () => { + afterEach(() => sinon.restore()); + + it('registers exactly the 5 compliant tools (no smartscraper/function/map/crawl)', () => { + const { names } = captureTools(true); + expect(names).to.deep.equal([ + 'browserless_agent', + 'browserless_export', + 'browserless_performance', + 'browserless_search', + 'browserless_skill', + ]); + }); + + it('full mode registers exactly the 9 tools (regression guard)', () => { + const { names } = captureTools(false); + expect(names).to.deep.equal([ + 'browserless_agent', + 'browserless_crawl', + 'browserless_export', + 'browserless_function', + 'browserless_map', + 'browserless_performance', + 'browserless_search', + 'browserless_skill', + 'browserless_smartscraper', + ]); + }); + + describe('non-tool surface (prompts + resources)', () => { + it('compliant mode omits the smartscraper-centric prompts + api-docs resource', () => { + const { promptNames, resourceUris } = captureTools(true); + expect(promptNames, 'no scrape prompts').to.not.include.members([ + 'scrape-url', + 'extract-content', + ]); + expect(resourceUris, 'no api-docs').to.not.include( + 'browserless://api-docs', + ); + // status resource stays on both surfaces (it reports the active surface). + expect(resourceUris, 'status kept').to.include('browserless://status'); + }); + + it('full mode serves the prompts + api-docs resource', () => { + const { promptNames, resourceUris } = captureTools(false); + expect(promptNames).to.include.members(['scrape-url', 'extract-content']); + expect(resourceUris).to.include.members([ + 'browserless://api-docs', + 'browserless://status', + ]); + }); + }); + + describe('agent schema', () => { + it('accepts a navigation command batch', () => { + const agent = captureTools(true).byName.get('browserless_agent')!; + expect(agent.parameters.safeParse({ commands: [VALID_GOTO] }).success).to + .be.true; + }); + + it('rejects an empty or missing commands array', () => { + const agent = captureTools(true).byName.get('browserless_agent')!; + expect( + agent.parameters.safeParse({ commands: [] }).success, + 'empty commands', + ).to.be.false; + expect( + agent.parameters.safeParse({ rationale: 'x' }).success, + 'missing commands', + ).to.be.false; + }); + + it('rejects the circumvention commands (solve/evaluate/loadSecret)', () => { + const agent = captureTools(true).byName.get('browserless_agent')!; + for (const method of ['solve', 'evaluate', 'loadSecret']) { + expect( + agent.parameters.safeParse({ commands: [{ method, params: {} }] }) + .success, + `method ${method} must be rejected`, + ).to.be.false; + } + }); + + it('full mode accepts solve and evaluate (they are removed only in compliant)', () => { + const full = captureTools(false).byName.get('browserless_agent')!; + expect( + full.parameters.safeParse({ commands: [{ method: 'solve' }] }).success, + 'full accepts solve', + ).to.be.true; + expect( + full.parameters.safeParse({ + commands: [{ method: 'evaluate', params: { content: '1+1' } }], + }).success, + 'full accepts evaluate', + ).to.be.true; + }); + + it('compliant rejects an unknown method (no raw-BQL passthrough arm)', () => { + const agent = captureTools(true).byName.get('browserless_agent')!; + expect( + agent.parameters.safeParse({ + commands: [{ method: 'totally-unknown-method', params: {} }], + }).success, + ).to.be.false; + }); + + it('COMPLIANT_AGENT_METHODS is an EXACT allowlist (a new prohibited command added to the compliant union fails here)', () => { + // Mirrors the top-level EXPECTED_KEYS guard for the command-method + // dimension: navigation + read + interaction (click/type/select/etc. are + // legitimate automation, not circumvention); the prohibited classes — + // solve/evaluate/loadSecret and top-level proxy/profile — stay out. + const EXPECTED_METHODS = [ + 'goto', + 'back', + 'forward', + 'reload', + 'snapshot', + 'getTabs', + 'switchTab', + 'createTab', + 'closeTab', + 'click', + 'type', + 'select', + 'checkbox', + 'hover', + 'scroll', + 'text', + 'html', + 'waitForSelector', + 'waitForNavigation', + 'waitForTimeout', + 'waitForRequest', + 'waitForResponse', + 'liveURL', + 'screenshot', + 'uploadFile', + 'getDownloads', + 'close', + ]; + expect([...COMPLIANT_AGENT_METHODS]).to.have.members(EXPECTED_METHODS); + expect(COMPLIANT_AGENT_METHODS.size).to.equal(EXPECTED_METHODS.length); + }); + + it('rejects proxy / profile / createProfile / raw method passthrough (strict)', () => { + const agent = captureTools(true).byName.get('browserless_agent')!; + expect( + agent.parameters.safeParse({ commands: [VALID_GOTO], proxy: {} }) + .success, + ).to.be.false; + // profile hydrates a saved auth session — no auth-profile capability on + // the compliant surface (parity with the hidden auth-profile skill). + expect( + agent.parameters.safeParse({ + commands: [VALID_GOTO], + profile: 'my-profile', + }).success, + 'profile must be rejected', + ).to.be.false; + expect( + agent.parameters.safeParse({ + commands: [VALID_GOTO], + createProfile: {}, + }).success, + ).to.be.false; + expect( + agent.parameters.safeParse({ + method: 'goto', + params: { url: 'https://example.com' }, + }).success, + ).to.be.false; + }); + + it('full mode accepts profile (auth-profile capability removed only in compliant)', () => { + const full = captureTools(false).byName.get('browserless_agent')!; + expect( + full.parameters.safeParse({ + commands: [VALID_GOTO], + profile: 'my-profile', + }).success, + ).to.be.true; + }); + }); + + describe('skill tool', () => { + it('enum excludes captchas/autonomous-login/auth-profile, keeps others', () => { + const skill = captureTools(true).byName.get('browserless_skill')!; + for (const id of ['captchas', 'autonomous-login', 'auth-profile']) { + expect( + skill.parameters.safeParse({ id }).success, + `skill ${id} must be rejected`, + ).to.be.false; + } + expect(skill.parameters.safeParse({ id: 'shadow-dom' }).success).to.be + .true; + }); + + it('enum is an EXACT allowlist: every registry skill accepted iff in the approved eight', () => { + // Independent oracle — the approved eight hardcoded here, NOT derived from + // COMPLIANT_SKILLS (which also feeds the enum). An accidental expansion of + // COMPLIANT_SKILLS would otherwise move both sides in lockstep and pass; + // pinning to this literal makes it fail instead. + const APPROVED = [ + 'shadow-dom', + 'cookie-consent', + 'modals', + 'snapshot-misses', + 'dynamic-content', + 'screenshots', + 'tabs', + 'file-transfers', + ]; + expect( + [...COMPLIANT_SKILLS].sort(), + 'COMPLIANT_SKILLS drifted from the approved eight', + ).to.deep.equal([...APPROVED].sort()); + + // Fail-closed lock: the schema accepts a registry skill iff it's approved + // (a denylist would let an un-classified skill through by default). + const approved = new Set(APPROVED); + const skill = captureTools(true).byName.get('browserless_skill')!; + for (const s of skillsRegistry) { + expect( + skill.parameters.safeParse({ id: s.id }).success, + `skill ${s.id}: accepted iff approved`, + ).to.equal(approved.has(s.id)); + } + }); + + it('description does not advertise captchas or autonomous-login', () => { + const skill = captureTools(true).byName.get('browserless_skill')!; + expect(skill.description).to.not.match(/captchas|autonomous-login/i); + }); + + it('description lists exactly the allowlisted skills (guards prose drift)', () => { + const desc = + captureTools(true).byName.get('browserless_skill')!.description; + for (const id of COMPLIANT_SKILLS) { + expect(desc, `description must mention ${id}`).to.contain(id); + } + // Every non-compliant skill must be absent — catches a skill dropped from + // COMPLIANT_SKILLS but left in the hand-kept prose (which would then lie). + for (const s of skillsRegistry) { + if (!COMPLIANT_SKILLS.has(s.id)) { + expect(desc, `description must not mention ${s.id}`).to.not.contain( + s.id, + ); + } + } + }); + + it('full mode keeps the excluded skills selectable (regression guard)', () => { + const skill = captureTools(false).byName.get('browserless_skill')!; + expect(skill.parameters.safeParse({ id: 'captchas' }).success).to.be.true; + }); + + it('compliant drops the `site` recipe lookup (strict); full keeps it', () => { + const conn = captureTools(true).byName.get('browserless_skill')!; + const full = captureTools(false).byName.get('browserless_skill')!; + // compliant schema exposes only `id` — no `site` recipe param advertised + const shape = ( + conn.parameters as unknown as { shape?: Record } + ).shape; + expect(Object.keys(shape ?? {})).to.deep.equal(['id']); + // and rejects a `site` arg outright (strict) + expect(conn.parameters.safeParse({ site: 'ebay.com' }).success).to.be + .false; + expect( + conn.parameters.safeParse({ id: 'shadow-dom', site: 'ebay.com' }) + .success, + 'stray site rejected even with a valid id', + ).to.be.false; + // full resolves site recipes + expect(full.parameters.safeParse({ site: 'ebay.com' }).success).to.be + .true; + }); + }); + + describe('descriptions and dropped params', () => { + it('smartscraper is absent in compliant mode, present in full', () => { + expect(captureTools(true).byName.has('browserless_smartscraper')).to.be + .false; + expect(captureTools(false).byName.has('browserless_smartscraper')).to.be + .true; + }); + + it('search rejects scrapeOptions (strict); full keeps it', () => { + const conn = captureTools(true).byName.get('browserless_search')!; + const full = captureTools(false).byName.get('browserless_search')!; + const withOpts = { query: 'q', scrapeOptions: { formats: ['markdown'] } }; + expect(conn.parameters.safeParse(withOpts).success, 'compliant rejects') + .to.be.false; + expect( + conn.parameters.safeParse({ query: 'q' }).success, + 'compliant accepts without it', + ).to.be.true; + expect(full.parameters.safeParse(withOpts).success, 'full keeps it').to.be + .true; + }); + + it('export rejects includeResources (strict); full keeps it', () => { + const conn = captureTools(true).byName.get('browserless_export')!; + const full = captureTools(false).byName.get('browserless_export')!; + const withRes = { url: 'https://example.com', includeResources: true }; + expect(conn.parameters.safeParse(withRes).success, 'compliant rejects').to + .be.false; + expect( + conn.parameters.safeParse({ url: 'https://example.com' }).success, + 'compliant accepts without it', + ).to.be.true; + expect(full.parameters.safeParse(withRes).success, 'full keeps it').to.be + .true; + }); + + it('export rejects profile / auth-session hydration (strict); full keeps it', () => { + const conn = captureTools(true).byName.get('browserless_export')!; + const full = captureTools(false).byName.get('browserless_export')!; + const withProfile = { url: 'https://example.com', profile: 'my-profile' }; + expect( + conn.parameters.safeParse(withProfile).success, + 'compliant rejects', + ).to.be.false; + expect(full.parameters.safeParse(withProfile).success, 'full keeps it').to + .be.true; + }); + + it('performance rejects profile / auth-session hydration (strict); full keeps it', () => { + const conn = captureTools(true).byName.get('browserless_performance')!; + const full = captureTools(false).byName.get('browserless_performance')!; + const withProfile = { url: 'https://example.com', profile: 'my-profile' }; + expect( + conn.parameters.safeParse(withProfile).success, + 'compliant rejects', + ).to.be.false; + expect(full.parameters.safeParse(withProfile).success, 'full keeps it').to + .be.true; + }); + + it('serves the de-fanged compliant descriptions (what a directory reviewer reads)', () => { + const { byName } = captureTools(true); + // agent: explicit restrictive posture + expect(byName.get('browserless_agent')!.description).to.match( + /do not use to bypass access controls/i, + ); + // search: no per-result-scraping relay language + expect(byName.get('browserless_search')!.description).to.not.match( + /scrape each/i, + ); + // export: no bulk-asset / ZIP language + expect(byName.get('browserless_export')!.description).to.not.match( + /zip|bundle all|includeResources/i, + ); + }); + }); + + describe('run()-layer defense-in-depth (schema bypass)', () => { + const mockCtx = { + reportProgress: sinon.stub().resolves(), + log: { + debug: sinon.stub(), + error: sinon.stub(), + info: sinon.stub(), + warn: sinon.stub(), + }, + session: undefined, + streamContent: sinon.stub().resolves(), + }; + + // Bypasses schema validation by calling execute() directly — the "schema + // mis-built" case the allowlist loop exists for and safeParse can't reach. + const compliantAgentExecute = () => { + const server = new FastMCP({ name: 'test', version: '0.1.0' }); + const spy = sinon.spy(server, 'addTool'); + registerSurface(server, { ...baseConfig, complianceMode: true }); + const agent = spy + .getCalls() + .find((c) => c.args[0].name === 'browserless_agent')!.args[0] as { + execute: (a: unknown, c: unknown) => Promise; + }; + return agent.execute; + }; + + it('rejects an unknown (non-allowlisted) method even when the schema is bypassed', async () => { + // Unknown, not a known-prohibited method: a denylist would let this + // through, so accepting it would prove the run-layer guard is not the + // allowlist it claims to be. (evaluate is covered by the batch-index test + // below and the schema-level test above.) + const execute = compliantAgentExecute(); + try { + await execute( + { commands: [{ method: 'totally-unknown-method', params: {} }] }, + mockCtx, + ); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.match(/not available/i); + } + }); + + it('rejects a prohibited method at a NON-ZERO batch index (checks every command)', async () => { + const execute = compliantAgentExecute(); + try { + // evaluate at index 1 — a loop that only checked commands[0] would leak. + await execute( + { + commands: [ + { method: 'goto', params: { url: 'https://example.com' } }, + { method: 'evaluate', params: { content: '1' } }, + ], + }, + mockCtx, + ); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.match(/not available/i); + } + }); + + it('rejects an empty/absent method (no bare passthrough)', async () => { + const execute = compliantAgentExecute(); + try { + await execute({}, mockCtx); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.match(/not available/i); + } + }); + + it('rejects proxy / auth profile / createProfile even when the schema is bypassed', async () => { + const execute = compliantAgentExecute(); + for (const extra of [ + { profile: 'my-profile' }, + { createProfile: { name: 'x' } }, + { proxy: { proxy: 'residential', proxyCountry: 'us' } }, + ]) { + try { + await execute({ commands: [VALID_GOTO], ...extra }, mockCtx); + expect.fail(`expected UserError for ${JSON.stringify(extra)}`); + } catch (err) { + expect((err as Error).message).to.match(/not available/i); + } + } + }); + + const compliantSkillExecute = () => { + const server = new FastMCP({ name: 'test', version: '0.1.0' }); + const spy = sinon.spy(server, 'addTool'); + registerSurface(server, { ...baseConfig, complianceMode: true }); + const skill = spy + .getCalls() + .find((c) => c.args[0].name === 'browserless_skill')!.args[0] as { + execute: (a: unknown, c: unknown) => Promise; + }; + return skill.execute; + }; + + it('skill run() refuses a restricted recipe even when the enum is bypassed', async () => { + const execute = compliantSkillExecute(); + try { + await execute({ id: 'captchas' }, mockCtx); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.match(/not available/i); + } + }); + + const compliantExecute = (toolName: string) => { + const server = new FastMCP({ name: 'test', version: '0.1.0' }); + const spy = sinon.spy(server, 'addTool'); + registerSurface(server, { ...baseConfig, complianceMode: true }); + return spy.getCalls().find((c) => c.args[0].name === toolName)! + .args[0] as { + execute: (a: unknown, c: unknown) => Promise; + }; + }; + + it('search run() rejects scrapeOptions even when the schema is bypassed', async () => { + const { execute } = compliantExecute('browserless_search'); + try { + await execute({ query: 'x', scrapeOptions: {} }, mockCtx); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.match(/not available/i); + } + }); + + it('export run() rejects includeResources even when the schema is bypassed', async () => { + const { execute } = compliantExecute('browserless_export'); + try { + await execute( + { url: 'https://example.com', includeResources: true }, + mockCtx, + ); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.match(/not available/i); + } + }); + + it('export run() rejects an auth profile even when the schema is bypassed', async () => { + const { execute } = compliantExecute('browserless_export'); + try { + await execute( + { url: 'https://example.com', profile: 'my-profile' }, + mockCtx, + ); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.match(/not available/i); + } + }); + + it('performance run() rejects an auth profile even when the schema is bypassed', async () => { + const { execute } = compliantExecute('browserless_performance'); + try { + await execute( + { url: 'https://example.com', profile: 'my-profile' }, + mockCtx, + ); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.match(/not available/i); + } + }); + }); + + describe('compliant tool schemas are exact allowlists (drift guard)', () => { + // Exact top-level key-set per tool — NOT a forbidden-name denylist. A future + // edit that adds ANY top-level field (evasion or not, any name) to a + // compliant tool fails here, regardless of what it's called. + const EXPECTED_KEYS: Record = { + browserless_agent: ['commands', 'rationale'], + browserless_export: [ + 'bestAttempt', + 'gotoOptions', + 'timeout', + 'url', + 'waitForTimeout', + ], + browserless_performance: ['budgets', 'categories', 'timeout', 'url'], + browserless_search: [ + 'categories', + 'country', + 'lang', + 'limit', + 'location', + 'query', + 'sources', + 'tbs', + 'timeout', + ], + browserless_skill: ['id'], + }; + + it('each compliant tool exposes exactly its expected top-level keys', () => { + const { byName } = captureTools(true); + expect([...byName.keys()].sort(), 'compliant tool set').to.deep.equal( + Object.keys(EXPECTED_KEYS).sort(), + ); + for (const [name, tool] of byName) { + const shape = ( + tool.parameters as unknown as { shape?: Record } + ).shape; + // Fail loudly if a schema is ever wrapped (e.g. ZodEffects with no + // `.shape`), so the guard can't silently go vacuous. + expect(shape, `${name} schema must be introspectable (a ZodObject)`).to + .not.be.undefined; + expect( + Object.keys(shape ?? {}).sort(), + `${name} top-level keys`, + ).to.deep.equal(EXPECTED_KEYS[name]); + } + }); + + // Cross-tool invariant: the compliant surface exposes ZERO auth-profile + // capability. A `profile` param hydrates a saved session's cookies/ + // localStorage (see profileField) — auth-session injection a directory + // reviewer would flag. No compliant tool may expose it on any surface. + it('no compliant tool exposes a `profile` (auth-session) parameter', () => { + const { byName } = captureTools(true); + for (const [name, tool] of byName) { + const shape = ( + tool.parameters as unknown as { shape?: Record } + ).shape; + expect( + Object.keys(shape ?? {}), + `${name} must not expose profile`, + ).to.not.include('profile'); + } + }); + }); + + describe('status resource reports the active surface', () => { + // Load the status resource via its no-token early-return branch (no network), + // and assert the machine-readable `surface` attestation a directory/uptime + // check reads. Guards an inverted ternary or a dropped field. + const loadStatus = async (complianceMode: boolean) => { + const server = new FastMCP({ name: 'test', version: '0.1.0' }); + const spy = sinon.spy(server, 'addResource'); + registerSurface(server, { + ...baseConfig, + browserlessToken: undefined, + complianceMode, + }); + const res = spy + .getCalls() + .find((c) => c.args[0].uri === 'browserless://status')!.args[0] as { + load: () => Promise<{ text: string }>; + }; + return JSON.parse((await res.load()).text); + }; + + it('reports surface "compliant" in compliant mode', async () => { + expect((await loadStatus(true)).surface).to.equal('compliant'); + }); + + it('reports surface "full" in full mode', async () => { + expect((await loadStatus(false)).surface).to.equal('full'); + }); + }); + + // Guards the skill AUTO-INJECTION path: even when detectSkills fires for a + // restricted recipe, visibleSkills must strip it in compliant mode so the + // recipe never lands in an agent reply. Full mode must keep every id. + describe('visibleSkills (auto-injection filter)', () => { + const triggered = [ + 'captchas', + 'shadow-dom', + 'autonomous-login', + 'modals', + 'auth-profile', + ] as SkillId[]; + + it('strips every restricted recipe in compliant mode', () => { + const out = visibleSkills(triggered, true); + expect(out).to.deep.equal(['shadow-dom', 'modals']); + for (const restricted of [ + 'captchas', + 'autonomous-login', + 'auth-profile', + ]) { + expect(out).to.not.include(restricted); + } + }); + + it('passes every id through in full mode', () => { + expect(visibleSkills(triggered, false)).to.deep.equal(triggered); + }); + + // Wiring guard: the leak path is a restricted recipe AUTO-FIRING on an + // allowed command (a login-page snapshot fires autonomous-login), which the + // enum/allowlist never see. detectVisibleSkills composes detectSkills + + // visibleSkills; if the compose is dropped, the recipe re-appears here. + const loginCtx = { + snapshot: { url: 'https://example.com/login', elements: [] }, + } as unknown as Parameters[0]; + + it('full mode auto-injects autonomous-login on a login page', () => { + expect( + detectVisibleSkills(loginCtx, createSkillState(), false), + ).to.include('autonomous-login'); + }); + + it('compliant mode strips the auto-fired autonomous-login recipe', () => { + expect( + detectVisibleSkills(loginCtx, createSkillState(), true), + ).to.not.include('autonomous-login'); + }); + }); + + describe('compliant skill BODIES are de-fanged', () => { + it('no allowlisted skill renders evaluate/captcha content in compliant mode', () => { + for (const id of COMPLIANT_SKILLS) { + const body = renderSkill(id, true); + expect(body, `${id} must not advise \`evaluate\``).to.not.match( + /\bevaluate\b/i, + ); + expect(body, `${id} must not list captcha selectors`).to.not.match( + /recaptcha|hcaptcha|turnstile|captcha/i, + ); + expect(body, `${id} marker comments must be stripped`).to.not.match( + /compliant-(omit|only)/, + ); + expect(body, `${id} still renders`).to.contain(`SKILL: ${id}`); + } + }); + + it('full mode retains the omitted content and strips both markers', () => { + const full = renderSkill('shadow-dom', false); + expect(full, 'full keeps evaluate guidance').to.match(/\bevaluate\b/); + expect(full, 'markers never leak to output').to.not.match( + /compliant-(omit|only)/, + ); + }); + + // Cutting the omitted path must not leave a listed problem with no remedy: + // the compliant-only block supplies the reduced-surface replacement. Assert + // it appears ONLY in compliant, so a revert of either marker is caught. + it('supplies compliant-only replacement guidance for the omitted paths', () => { + const cases: ReadonlyArray<[SkillId, RegExp]> = [ + ['snapshot-misses', /read the answer visually/i], + ['shadow-dom', /for interaction, not reading/i], + ]; + for (const [id, prose] of cases) { + expect( + renderSkill(id, true), + `${id} compliant render must include the replacement`, + ).to.match(prose); + expect( + renderSkill(id, false), + `${id} full render must not include the compliant-only block`, + ).to.not.match(prose); + } + }); + + // Regression guard: the omit and its compliant-only replacement share step + // numbers (2, 3) so exactly one is ever present — the recipe must read as a + // contiguous 1..N in BOTH modes, never a gap where a step was cut. + it('renders contiguous recipe numbering in both modes', () => { + for (const compliant of [true, false]) { + const body = renderSkill('snapshot-misses', compliant); + const steps = (body.match(/^\d+\. /gm) ?? []).map((s) => + parseInt(s, 10), + ); + expect(steps, `snapshot-misses steps (compliant=${compliant})`).to.eql([ + 1, 2, 3, 4, + ]); + } + }); + }); + + // The block strippers only match exact, balanced markers; a malformed one + // would silently retain a prohibited block in the compliant render. The + // load-time validator turns that latent per-render leak into a boot failure. + describe('marker validation is fail-closed (guards against a silent leak)', () => { + const okBody = 'a\n\nx\n\nb'; + + it('accepts well-formed omit and only blocks', () => { + expect(() => validateMarkers(okBody, 'f')).to.not.throw(); + expect(() => + validateMarkers( + '\ny\n', + 'f', + ), + ).to.not.throw(); + }); + + it('every shipped skill body passes (regression: files stay well-formed)', () => { + for (const s of skillsRegistry) { + expect(() => validateMarkers(s.body, s.path), s.id).to.not.throw(); + } + }); + + const bad: ReadonlyArray<[string, string, RegExp]> = [ + ['unclosed', '\nx', /unclosed/i], + ['close without open', 'x\n', /unbalanced/i], + [ + 'mismatched kind', + '\nx\n', + /unbalanced/i, + ], + [ + 'nested', + 'x', + /nested/i, + ], + [ + 'typo', + '\nx\n', + /malformed/i, + ], + [ + 'extra spacing', + '\nx\n', + /malformed/i, + ], + ]; + for (const [name, body, re] of bad) { + it(`throws on a ${name} marker`, () => { + expect(() => validateMarkers(body, 'f')).to.throw(re); + }); + } + }); + + // The agent reply path appends auto-injected skill bodies + a site-recipe + // pointer. Both must respect the surface: compliant de-fangs the skills and + // suppresses site recipes (which prescribe proxy/evaluate/login). Testing the + // pure builder locks both call-sites' `compliant` threading without a live + // session (a regression flipping either to the wrong branch fails here). + describe('agent reply extras respect the surface', () => { + const RECIPE_URL = 'https://airbnb.com'; // a host with a bundled site recipe + + it('compliant: de-fangs skills + suppresses site recipes', () => { + const { skills, siteNotice } = buildSurfaceExtras( + true, + ['shadow-dom'], + RECIPE_URL, + new Set(), + ); + expect(siteNotice, 'no site-recipe pointer on compliant').to.equal(''); + expect(skills, 'skill body de-fanged').to.not.match( + /\bevaluate\b|recaptcha|hcaptcha|turnstile/i, + ); + expect(skills.length, 'skill still rendered').to.be.greaterThan(200); + }); + + it('full: keeps site recipes + full skill guidance', () => { + const { skills, siteNotice } = buildSurfaceExtras( + false, + ['shadow-dom'], + RECIPE_URL, + new Set(), + ); + expect(siteNotice, 'site-recipe pointer surfaces on full').to.match( + /SITE RECIPE/i, + ); + expect(skills, 'full retains evaluate guidance').to.match( + /\bevaluate\b/i, + ); + }); + }); +}); diff --git a/test/tools/crawl.spec.ts b/test/tools/crawl.spec.ts index f54d463..8d708ed 100644 --- a/test/tools/crawl.spec.ts +++ b/test/tools/crawl.spec.ts @@ -14,6 +14,7 @@ const mockConfig: McpConfig = { maxRetries: 0, cacheTtlMs: 0, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '', diff --git a/test/tools/export.spec.ts b/test/tools/export.spec.ts index 54a7903..c1b1d55 100644 --- a/test/tools/export.spec.ts +++ b/test/tools/export.spec.ts @@ -14,6 +14,7 @@ const mockConfig: McpConfig = { maxRetries: 0, cacheTtlMs: 0, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '', diff --git a/test/tools/function.spec.ts b/test/tools/function.spec.ts index 426f1a8..893f9cd 100644 --- a/test/tools/function.spec.ts +++ b/test/tools/function.spec.ts @@ -19,6 +19,7 @@ const mockConfig: McpConfig = { maxRetries: 0, cacheTtlMs: 0, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '', diff --git a/test/tools/map.spec.ts b/test/tools/map.spec.ts index 843965f..c737738 100644 --- a/test/tools/map.spec.ts +++ b/test/tools/map.spec.ts @@ -14,6 +14,7 @@ const mockConfig: McpConfig = { maxRetries: 0, cacheTtlMs: 0, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '', diff --git a/test/tools/performance.spec.ts b/test/tools/performance.spec.ts index e67809e..c7d9356 100644 --- a/test/tools/performance.spec.ts +++ b/test/tools/performance.spec.ts @@ -14,6 +14,7 @@ const mockConfig: McpConfig = { maxRetries: 0, cacheTtlMs: 0, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '', diff --git a/test/tools/search.spec.ts b/test/tools/search.spec.ts index ea4a5da..5009e3c 100644 --- a/test/tools/search.spec.ts +++ b/test/tools/search.spec.ts @@ -14,6 +14,7 @@ const mockConfig: McpConfig = { maxRetries: 0, cacheTtlMs: 0, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '', diff --git a/test/tools/smartscraper.spec.ts b/test/tools/smartscraper.spec.ts index 80bbdd7..ad4928a 100644 --- a/test/tools/smartscraper.spec.ts +++ b/test/tools/smartscraper.spec.ts @@ -14,6 +14,7 @@ const mockConfig: McpConfig = { maxRetries: 0, cacheTtlMs: 0, analyticsEnabled: false, + complianceMode: false, sqsRegion: 'us-east-1', oauthEnabled: false, supabaseUrl: '',