feat: add sandbox browser control allowlists

This commit is contained in:
Peter Steinberger
2026-01-11 01:52:23 +01:00
parent b0b4b33b6b
commit 07be761779
12 changed files with 165 additions and 5 deletions

View File

@@ -19,7 +19,7 @@
## 2026.1.11-1 ## 2026.1.11-1
### New Features and Changes ### 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 ## 2026.1.10-4

View File

@@ -1377,6 +1377,9 @@ Legacy: `perSession` is still supported (`true` → `scope: "session"`,
headless: false, headless: false,
enableNoVnc: true, enableNoVnc: true,
allowHostControl: false, allowHostControl: false,
allowedControlUrls: ["http://10.0.0.42:18791"],
allowedControlHosts: ["browser.lab.local", "10.0.0.42"],
allowedControlPorts: [18791],
autoStart: true, autoStart: true,
autoStartTimeoutMs: 12000 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 via the browser tool (`target: "host"`). Leave this off if you want strict
sandbox isolation. 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) ### `models` (custom providers + base URLs)
Clawdbot uses the **pi-coding-agent** model catalog. You can add custom providers Clawdbot uses the **pi-coding-agent** model catalog. You can add custom providers

View File

@@ -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. - 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`. 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. - `agents.defaults.sandbox.browser.allowHostControl` lets sandboxed sessions target the host browser explicitly.
- Optional allowlists gate `target: "custom"`: `allowedControlUrls`, `allowedControlHosts`, `allowedControlPorts`.
Not sandboxed: Not sandboxed:
- The Gateway process itself. - The Gateway process itself.

View File

@@ -244,5 +244,6 @@ How it maps:
- `controlUrl` sets `target: "custom"` implicitly (remote control server). - `controlUrl` sets `target: "custom"` implicitly (remote control server).
- In sandboxed sessions, `target: "host"` requires `agents.defaults.sandbox.browser.allowHostControl=true`. - 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`. - 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. This keeps the agent deterministic and avoids brittle selectors.

View File

@@ -18,6 +18,9 @@ import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
export function createClawdbotTools(options?: { export function createClawdbotTools(options?: {
browserControlUrl?: string; browserControlUrl?: string;
allowHostBrowserControl?: boolean; allowHostBrowserControl?: boolean;
allowedControlUrls?: string[];
allowedControlHosts?: string[];
allowedControlPorts?: number[];
agentSessionKey?: string; agentSessionKey?: string;
agentProvider?: GatewayMessageProvider; agentProvider?: GatewayMessageProvider;
agentAccountId?: string; agentAccountId?: string;
@@ -41,6 +44,9 @@ export function createClawdbotTools(options?: {
createBrowserTool({ createBrowserTool({
defaultControlUrl: options?.browserControlUrl, defaultControlUrl: options?.browserControlUrl,
allowHostControl: options?.allowHostBrowserControl, allowHostControl: options?.allowHostBrowserControl,
allowedControlUrls: options?.allowedControlUrls,
allowedControlHosts: options?.allowedControlHosts,
allowedControlPorts: options?.allowedControlPorts,
}), }),
createCanvasTool(), createCanvasTool(),
createNodesTool(), createNodesTool(),

View File

@@ -480,6 +480,9 @@ type EmbeddedSandboxInfo = {
browserControlUrl?: string; browserControlUrl?: string;
browserNoVncUrl?: string; browserNoVncUrl?: string;
hostBrowserAllowed?: boolean; hostBrowserAllowed?: boolean;
allowedControlUrls?: string[];
allowedControlHosts?: string[];
allowedControlPorts?: number[];
elevated?: { elevated?: {
allowed: boolean; allowed: boolean;
defaultLevel: "on" | "off"; defaultLevel: "on" | "off";
@@ -572,6 +575,9 @@ export function buildEmbeddedSandboxInfo(
browserControlUrl: sandbox.browser?.controlUrl, browserControlUrl: sandbox.browser?.controlUrl,
browserNoVncUrl: sandbox.browser?.noVncUrl, browserNoVncUrl: sandbox.browser?.noVncUrl,
hostBrowserAllowed: sandbox.browserAllowHostControl, hostBrowserAllowed: sandbox.browserAllowHostControl,
allowedControlUrls: sandbox.browserAllowedControlUrls,
allowedControlHosts: sandbox.browserAllowedControlHosts,
allowedControlPorts: sandbox.browserAllowedControlPorts,
...(elevatedAllowed ...(elevatedAllowed
? { ? {
elevated: { elevated: {

View File

@@ -627,6 +627,9 @@ export function createClawdbotCodingTools(options?: {
...createClawdbotTools({ ...createClawdbotTools({
browserControlUrl: sandbox?.browser?.controlUrl, browserControlUrl: sandbox?.browser?.controlUrl,
allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true, allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true,
allowedControlUrls: sandbox?.browserAllowedControlUrls,
allowedControlHosts: sandbox?.browserAllowedControlHosts,
allowedControlPorts: sandbox?.browserAllowedControlPorts,
agentSessionKey: options?.sessionKey, agentSessionKey: options?.sessionKey,
agentProvider: resolveGatewayMessageProvider(options?.messageProvider), agentProvider: resolveGatewayMessageProvider(options?.messageProvider),
agentAccountId: options?.agentAccountId, agentAccountId: options?.agentAccountId,

View File

@@ -79,6 +79,9 @@ export type SandboxBrowserConfig = {
headless: boolean; headless: boolean;
enableNoVnc: boolean; enableNoVnc: boolean;
allowHostControl: boolean; allowHostControl: boolean;
allowedControlUrls?: string[];
allowedControlHosts?: string[];
allowedControlPorts?: number[];
autoStart: boolean; autoStart: boolean;
autoStartTimeoutMs: number; autoStartTimeoutMs: number;
}; };
@@ -140,6 +143,9 @@ export type SandboxContext = {
docker: SandboxDockerConfig; docker: SandboxDockerConfig;
tools: SandboxToolPolicy; tools: SandboxToolPolicy;
browserAllowHostControl: boolean; browserAllowHostControl: boolean;
browserAllowedControlUrls?: string[];
browserAllowedControlHosts?: string[];
browserAllowedControlPorts?: number[];
browser?: SandboxBrowserContext; browser?: SandboxBrowserContext;
}; };
@@ -310,6 +316,12 @@ export function resolveSandboxBrowserConfig(params: {
const agentBrowser = const agentBrowser =
params.scope === "shared" ? undefined : params.agentBrowser; params.scope === "shared" ? undefined : params.agentBrowser;
const globalBrowser = params.globalBrowser; const globalBrowser = params.globalBrowser;
const allowedControlUrls =
agentBrowser?.allowedControlUrls ?? globalBrowser?.allowedControlUrls;
const allowedControlHosts =
agentBrowser?.allowedControlHosts ?? globalBrowser?.allowedControlHosts;
const allowedControlPorts =
agentBrowser?.allowedControlPorts ?? globalBrowser?.allowedControlPorts;
return { return {
enabled: agentBrowser?.enabled ?? globalBrowser?.enabled ?? false, enabled: agentBrowser?.enabled ?? globalBrowser?.enabled ?? false,
image: image:
@@ -339,6 +351,18 @@ export function resolveSandboxBrowserConfig(params: {
agentBrowser?.allowHostControl ?? agentBrowser?.allowHostControl ??
globalBrowser?.allowHostControl ?? globalBrowser?.allowHostControl ??
false, 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, autoStart: agentBrowser?.autoStart ?? globalBrowser?.autoStart ?? true,
autoStartTimeoutMs: autoStartTimeoutMs:
agentBrowser?.autoStartTimeoutMs ?? agentBrowser?.autoStartTimeoutMs ??
@@ -1331,6 +1355,9 @@ export async function resolveSandboxContext(params: {
docker: cfg.docker, docker: cfg.docker,
tools: cfg.tools, tools: cfg.tools,
browserAllowHostControl: cfg.browser.allowHostControl, browserAllowHostControl: cfg.browser.allowHostControl,
browserAllowedControlUrls: cfg.browser.allowedControlUrls,
browserAllowedControlHosts: cfg.browser.allowedControlHosts,
browserAllowedControlPorts: cfg.browser.allowedControlPorts,
browser: browser ?? undefined, browser: browser ?? undefined,
}; };
} }

View File

@@ -249,6 +249,21 @@ export function buildAgentSystemPrompt(params: {
: params.sandboxInfo.hostBrowserAllowed === false : params.sandboxInfo.hostBrowserAllowed === false
? "Host browser control: blocked." ? "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 params.sandboxInfo.elevated?.allowed
? "Elevated bash is available for this session." ? "Elevated bash is available for this session."
: "", : "",

View File

@@ -136,6 +136,9 @@ function resolveBrowserBaseUrl(params: {
controlUrl?: string; controlUrl?: string;
defaultControlUrl?: string; defaultControlUrl?: string;
allowHostControl?: boolean; allowHostControl?: boolean;
allowedControlUrls?: string[];
allowedControlHosts?: string[];
allowedControlPorts?: number[];
}) { }) {
const cfg = loadConfig(); const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser); const resolved = resolveBrowserConfig(cfg.browser);
@@ -145,6 +148,51 @@ function resolveBrowserBaseUrl(params: {
params.target ?? params.target ??
(normalizedControlUrl ? "custom" : normalizedDefault ? "sandbox" : "host"); (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) { if (target !== "custom" && params.target && normalizedControlUrl) {
throw new Error('controlUrl is only supported with target="custom".'); throw new Error('controlUrl is only supported with target="custom".');
} }
@@ -153,7 +201,9 @@ function resolveBrowserBaseUrl(params: {
if (!normalizedControlUrl) { if (!normalizedControlUrl) {
throw new Error("Custom browser target requires controlUrl."); throw new Error("Custom browser target requires controlUrl.");
} }
return normalizedControlUrl.replace(/\/$/, ""); const normalized = normalizedControlUrl.replace(/\/$/, "");
assertAllowedControlUrl(normalized);
return normalized;
} }
if (target === "sandbox") { if (target === "sandbox") {
@@ -173,18 +223,40 @@ function resolveBrowserBaseUrl(params: {
"Browser control is disabled. Set browser.enabled=true in ~/.clawdbot/clawdbot.json.", "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?: { export function createBrowserTool(opts?: {
defaultControlUrl?: string; defaultControlUrl?: string;
allowHostControl?: boolean; allowHostControl?: boolean;
allowedControlUrls?: string[];
allowedControlHosts?: string[];
allowedControlPorts?: number[];
}): AnyAgentTool { }): 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 { return {
label: "Browser", label: "Browser",
name: "browser", name: "browser",
description: 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.", "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, parameters: BrowserToolSchema,
execute: async (_toolCallId, args) => { execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>; const params = args as Record<string, unknown>;
@@ -201,6 +273,9 @@ export function createBrowserTool(opts?: {
controlUrl, controlUrl,
defaultControlUrl: opts?.defaultControlUrl, defaultControlUrl: opts?.defaultControlUrl,
allowHostControl: opts?.allowHostControl, allowHostControl: opts?.allowHostControl,
allowedControlUrls: opts?.allowedControlUrls,
allowedControlHosts: opts?.allowedControlHosts,
allowedControlPorts: opts?.allowedControlPorts,
}); });
switch (action) { switch (action) {

View File

@@ -865,6 +865,21 @@ export type SandboxBrowserSettings = {
* Default: false. * Default: false.
*/ */
allowHostControl?: boolean; 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 * When true (default), sandboxed browser control will try to start/reattach to
* the sandbox browser container when a tool call needs it. * the sandbox browser container when a tool call needs it.

View File

@@ -746,6 +746,9 @@ const SandboxBrowserSchema = z
headless: z.boolean().optional(), headless: z.boolean().optional(),
enableNoVnc: z.boolean().optional(), enableNoVnc: z.boolean().optional(),
allowHostControl: 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(), autoStart: z.boolean().optional(),
autoStartTimeoutMs: z.number().int().positive().optional(), autoStartTimeoutMs: z.number().int().positive().optional(),
}) })