From 07be761779d8870902090ab430949b30d69c69be Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 11 Jan 2026 01:52:23 +0100 Subject: [PATCH] feat: add sandbox browser control allowlists --- CHANGELOG.md | 2 +- docs/gateway/configuration.md | 8 +++ docs/gateway/sandboxing.md | 1 + docs/tools/browser.md | 1 + src/agents/clawdbot-tools.ts | 6 +++ src/agents/pi-embedded-runner.ts | 6 +++ src/agents/pi-tools.ts | 3 ++ src/agents/sandbox.ts | 27 +++++++++++ src/agents/system-prompt.ts | 15 ++++++ src/agents/tools/browser-tool.ts | 83 ++++++++++++++++++++++++++++++-- src/config/types.ts | 15 ++++++ src/config/zod-schema.ts | 3 ++ 12 files changed, 165 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3de14f517..c57b4fa6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ ## 2026.1.11-1 ### New Features and Changes -- Agents/Browser: add `browser.target` (sandbox/host/custom) with sandbox host-control gating via `agents.defaults.sandbox.browser.allowHostControl`, and expand browser tool docs (remote control, profiles, internals). +- Agents/Browser: add `browser.target` (sandbox/host/custom) with sandbox host-control gating via `agents.defaults.sandbox.browser.allowHostControl`, allowlists for custom control URLs/hosts/ports, and expand browser tool docs (remote control, profiles, internals). ## 2026.1.10-4 diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index a2d7ebddd..dcba68f95 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1377,6 +1377,9 @@ Legacy: `perSession` is still supported (`true` → `scope: "session"`, headless: false, enableNoVnc: true, allowHostControl: false, + allowedControlUrls: ["http://10.0.0.42:18791"], + allowedControlHosts: ["browser.lab.local", "10.0.0.42"], + allowedControlPorts: [18791], autoStart: true, autoStartTimeoutMs: 12000 }, @@ -1424,6 +1427,11 @@ sandboxed sessions to explicitly target the **host** browser control server via the browser tool (`target: "host"`). Leave this off if you want strict sandbox isolation. +Allowlists for remote control: +- `allowedControlUrls`: exact control URLs permitted for `target: "custom"`. +- `allowedControlHosts`: hostnames permitted (hostname only, no port). +- `allowedControlPorts`: ports permitted (defaults: http=80, https=443). + ### `models` (custom providers + base URLs) Clawdbot uses the **pi-coding-agent** model catalog. You can add custom providers diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 0dd5eef72..3e61f2c99 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -22,6 +22,7 @@ and process access when the model does something dumb. - By default, the sandbox browser auto-starts (ensures CDP is reachable) when the browser tool needs it. Configure via `agents.defaults.sandbox.browser.autoStart` and `agents.defaults.sandbox.browser.autoStartTimeoutMs`. - `agents.defaults.sandbox.browser.allowHostControl` lets sandboxed sessions target the host browser explicitly. + - Optional allowlists gate `target: "custom"`: `allowedControlUrls`, `allowedControlHosts`, `allowedControlPorts`. Not sandboxed: - The Gateway process itself. diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 3b29ab680..8dfe11961 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -244,5 +244,6 @@ How it maps: - `controlUrl` sets `target: "custom"` implicitly (remote control server). - In sandboxed sessions, `target: "host"` requires `agents.defaults.sandbox.browser.allowHostControl=true`. - If `target` is omitted: sandboxed sessions default to `sandbox`, non-sandbox sessions default to `host`. + - Sandbox allowlists can restrict `target: "custom"` to specific URLs/hosts/ports. This keeps the agent deterministic and avoids brittle selectors. diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index 7b5a9714a..2506df014 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -18,6 +18,9 @@ import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; export function createClawdbotTools(options?: { browserControlUrl?: string; allowHostBrowserControl?: boolean; + allowedControlUrls?: string[]; + allowedControlHosts?: string[]; + allowedControlPorts?: number[]; agentSessionKey?: string; agentProvider?: GatewayMessageProvider; agentAccountId?: string; @@ -41,6 +44,9 @@ export function createClawdbotTools(options?: { createBrowserTool({ defaultControlUrl: options?.browserControlUrl, allowHostControl: options?.allowHostBrowserControl, + allowedControlUrls: options?.allowedControlUrls, + allowedControlHosts: options?.allowedControlHosts, + allowedControlPorts: options?.allowedControlPorts, }), createCanvasTool(), createNodesTool(), diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 6b52d5e12..db64c6048 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -480,6 +480,9 @@ type EmbeddedSandboxInfo = { browserControlUrl?: string; browserNoVncUrl?: string; hostBrowserAllowed?: boolean; + allowedControlUrls?: string[]; + allowedControlHosts?: string[]; + allowedControlPorts?: number[]; elevated?: { allowed: boolean; defaultLevel: "on" | "off"; @@ -572,6 +575,9 @@ export function buildEmbeddedSandboxInfo( browserControlUrl: sandbox.browser?.controlUrl, browserNoVncUrl: sandbox.browser?.noVncUrl, hostBrowserAllowed: sandbox.browserAllowHostControl, + allowedControlUrls: sandbox.browserAllowedControlUrls, + allowedControlHosts: sandbox.browserAllowedControlHosts, + allowedControlPorts: sandbox.browserAllowedControlPorts, ...(elevatedAllowed ? { elevated: { diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 40bfe1d75..f0de8a707 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -627,6 +627,9 @@ export function createClawdbotCodingTools(options?: { ...createClawdbotTools({ browserControlUrl: sandbox?.browser?.controlUrl, allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true, + allowedControlUrls: sandbox?.browserAllowedControlUrls, + allowedControlHosts: sandbox?.browserAllowedControlHosts, + allowedControlPorts: sandbox?.browserAllowedControlPorts, agentSessionKey: options?.sessionKey, agentProvider: resolveGatewayMessageProvider(options?.messageProvider), agentAccountId: options?.agentAccountId, diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index f0e49205b..c6afcc16e 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -79,6 +79,9 @@ export type SandboxBrowserConfig = { headless: boolean; enableNoVnc: boolean; allowHostControl: boolean; + allowedControlUrls?: string[]; + allowedControlHosts?: string[]; + allowedControlPorts?: number[]; autoStart: boolean; autoStartTimeoutMs: number; }; @@ -140,6 +143,9 @@ export type SandboxContext = { docker: SandboxDockerConfig; tools: SandboxToolPolicy; browserAllowHostControl: boolean; + browserAllowedControlUrls?: string[]; + browserAllowedControlHosts?: string[]; + browserAllowedControlPorts?: number[]; browser?: SandboxBrowserContext; }; @@ -310,6 +316,12 @@ export function resolveSandboxBrowserConfig(params: { const agentBrowser = params.scope === "shared" ? undefined : params.agentBrowser; const globalBrowser = params.globalBrowser; + const allowedControlUrls = + agentBrowser?.allowedControlUrls ?? globalBrowser?.allowedControlUrls; + const allowedControlHosts = + agentBrowser?.allowedControlHosts ?? globalBrowser?.allowedControlHosts; + const allowedControlPorts = + agentBrowser?.allowedControlPorts ?? globalBrowser?.allowedControlPorts; return { enabled: agentBrowser?.enabled ?? globalBrowser?.enabled ?? false, image: @@ -339,6 +351,18 @@ export function resolveSandboxBrowserConfig(params: { agentBrowser?.allowHostControl ?? globalBrowser?.allowHostControl ?? false, + allowedControlUrls: + Array.isArray(allowedControlUrls) && allowedControlUrls.length > 0 + ? allowedControlUrls + : undefined, + allowedControlHosts: + Array.isArray(allowedControlHosts) && allowedControlHosts.length > 0 + ? allowedControlHosts + : undefined, + allowedControlPorts: + Array.isArray(allowedControlPorts) && allowedControlPorts.length > 0 + ? allowedControlPorts + : undefined, autoStart: agentBrowser?.autoStart ?? globalBrowser?.autoStart ?? true, autoStartTimeoutMs: agentBrowser?.autoStartTimeoutMs ?? @@ -1331,6 +1355,9 @@ export async function resolveSandboxContext(params: { docker: cfg.docker, tools: cfg.tools, browserAllowHostControl: cfg.browser.allowHostControl, + browserAllowedControlUrls: cfg.browser.allowedControlUrls, + browserAllowedControlHosts: cfg.browser.allowedControlHosts, + browserAllowedControlPorts: cfg.browser.allowedControlPorts, browser: browser ?? undefined, }; } diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 826dc788e..89346148d 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -249,6 +249,21 @@ export function buildAgentSystemPrompt(params: { : params.sandboxInfo.hostBrowserAllowed === false ? "Host browser control: blocked." : "", + params.sandboxInfo.allowedControlUrls?.length + ? `Browser control URL allowlist: ${params.sandboxInfo.allowedControlUrls.join( + ", ", + )}` + : "", + params.sandboxInfo.allowedControlHosts?.length + ? `Browser control host allowlist: ${params.sandboxInfo.allowedControlHosts.join( + ", ", + )}` + : "", + params.sandboxInfo.allowedControlPorts?.length + ? `Browser control port allowlist: ${params.sandboxInfo.allowedControlPorts.join( + ", ", + )}` + : "", params.sandboxInfo.elevated?.allowed ? "Elevated bash is available for this session." : "", diff --git a/src/agents/tools/browser-tool.ts b/src/agents/tools/browser-tool.ts index 0839de1c3..bf6566c59 100644 --- a/src/agents/tools/browser-tool.ts +++ b/src/agents/tools/browser-tool.ts @@ -136,6 +136,9 @@ function resolveBrowserBaseUrl(params: { controlUrl?: string; defaultControlUrl?: string; allowHostControl?: boolean; + allowedControlUrls?: string[]; + allowedControlHosts?: string[]; + allowedControlPorts?: number[]; }) { const cfg = loadConfig(); const resolved = resolveBrowserConfig(cfg.browser); @@ -145,6 +148,51 @@ function resolveBrowserBaseUrl(params: { params.target ?? (normalizedControlUrl ? "custom" : normalizedDefault ? "sandbox" : "host"); + const assertAllowedControlUrl = (url: string) => { + const allowedUrls = params.allowedControlUrls?.map((entry) => + entry.trim().replace(/\/$/, ""), + ); + const allowedHosts = params.allowedControlHosts?.map((entry) => + entry.trim().toLowerCase(), + ); + const allowedPorts = params.allowedControlPorts; + if ( + !allowedUrls?.length && + !allowedHosts?.length && + !allowedPorts?.length + ) { + return; + } + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error(`Invalid browser controlUrl: ${url}`); + } + const normalizedUrl = parsed.toString().replace(/\/$/, ""); + if (allowedUrls?.length && !allowedUrls.includes(normalizedUrl)) { + throw new Error("Browser controlUrl is not in the allowed URL list."); + } + if (allowedHosts?.length && !allowedHosts.includes(parsed.hostname)) { + throw new Error( + "Browser controlUrl hostname is not in the allowed host list.", + ); + } + if (allowedPorts?.length) { + const port = + parsed.port?.trim() !== "" + ? Number(parsed.port) + : parsed.protocol === "https:" + ? 443 + : 80; + if (!Number.isFinite(port) || !allowedPorts.includes(port)) { + throw new Error( + "Browser controlUrl port is not in the allowed port list.", + ); + } + } + }; + if (target !== "custom" && params.target && normalizedControlUrl) { throw new Error('controlUrl is only supported with target="custom".'); } @@ -153,7 +201,9 @@ function resolveBrowserBaseUrl(params: { if (!normalizedControlUrl) { throw new Error("Custom browser target requires controlUrl."); } - return normalizedControlUrl.replace(/\/$/, ""); + const normalized = normalizedControlUrl.replace(/\/$/, ""); + assertAllowedControlUrl(normalized); + return normalized; } if (target === "sandbox") { @@ -173,18 +223,40 @@ function resolveBrowserBaseUrl(params: { "Browser control is disabled. Set browser.enabled=true in ~/.clawdbot/clawdbot.json.", ); } - return resolved.controlUrl.replace(/\/$/, ""); + const normalized = resolved.controlUrl.replace(/\/$/, ""); + assertAllowedControlUrl(normalized); + return normalized; } export function createBrowserTool(opts?: { defaultControlUrl?: string; allowHostControl?: boolean; + allowedControlUrls?: string[]; + allowedControlHosts?: string[]; + allowedControlPorts?: number[]; }): AnyAgentTool { + const targetDefault = opts?.defaultControlUrl ? "sandbox" : "host"; + const hostHint = + opts?.allowHostControl === false + ? "Host target blocked by policy." + : "Host target allowed."; + const allowlistHint = + opts?.allowedControlUrls?.length || + opts?.allowedControlHosts?.length || + opts?.allowedControlPorts?.length + ? "Custom targets are restricted by sandbox allowlists." + : "Custom targets are unrestricted."; return { label: "Browser", name: "browser", - description: - "Control clawd's dedicated browser (status/start/stop/tabs/open/snapshot/screenshot/actions). Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.", + description: [ + "Control clawd's dedicated browser (status/start/stop/tabs/open/snapshot/screenshot/actions).", + "Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.", + `target selects browser location (sandbox|host|custom). Default: ${targetDefault}.`, + "controlUrl implies target=custom (remote control server).", + hostHint, + allowlistHint, + ].join(" "), parameters: BrowserToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; @@ -201,6 +273,9 @@ export function createBrowserTool(opts?: { controlUrl, defaultControlUrl: opts?.defaultControlUrl, allowHostControl: opts?.allowHostControl, + allowedControlUrls: opts?.allowedControlUrls, + allowedControlHosts: opts?.allowedControlHosts, + allowedControlPorts: opts?.allowedControlPorts, }); switch (action) { diff --git a/src/config/types.ts b/src/config/types.ts index e223853aa..d0b009afc 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -865,6 +865,21 @@ export type SandboxBrowserSettings = { * Default: false. */ allowHostControl?: boolean; + /** + * Allowlist of exact control URLs for target="custom". + * When set, any custom controlUrl must match this list. + */ + allowedControlUrls?: string[]; + /** + * Allowlist of hostnames for control URLs (hostname only, no ports). + * When set, controlUrl hostname must match. + */ + allowedControlHosts?: string[]; + /** + * Allowlist of ports for control URLs. + * When set, controlUrl port must match (defaults: http=80, https=443). + */ + allowedControlPorts?: number[]; /** * When true (default), sandboxed browser control will try to start/reattach to * the sandbox browser container when a tool call needs it. diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 5fac4433d..eb5d0a1e0 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -746,6 +746,9 @@ const SandboxBrowserSchema = z headless: z.boolean().optional(), enableNoVnc: z.boolean().optional(), allowHostControl: z.boolean().optional(), + allowedControlUrls: z.array(z.string()).optional(), + allowedControlHosts: z.array(z.string()).optional(), + allowedControlPorts: z.array(z.number().int().positive()).optional(), autoStart: z.boolean().optional(), autoStartTimeoutMs: z.number().int().positive().optional(), })