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
### 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

View File

@@ -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

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.
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.

View File

@@ -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.

View File

@@ -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(),

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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,
};
}

View File

@@ -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."
: "",

View File

@@ -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<string, unknown>;
@@ -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) {

View File

@@ -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.

View File

@@ -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(),
})