From 3bdc2d6ee6ac4da7f74a453def60c83e0d3e475a Mon Sep 17 00:00:00 2001 From: Ariel Rahmane Date: Thu, 2 Apr 2026 09:18:06 +1100 Subject: [PATCH 1/4] feat: add navigationDomains and resourceDomains --- README.md | 15 ++- cli/src/commands.rs | 2 + cli/src/connection.rs | 8 ++ cli/src/flags.rs | 8 ++ cli/src/main.rs | 10 +- cli/src/native/actions.rs | 61 ++++++++--- cli/src/native/network.rs | 188 ++++++++++++++++++++++++++++++++-- cli/src/output.rs | 8 +- skills/agent-browser/SKILL.md | 21 +++- 9 files changed, 288 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index fcc5893bc..f7b165644 100644 --- a/README.md +++ b/README.md @@ -427,7 +427,18 @@ agent-browser --session-name secure open example.com agent-browser includes security features for safe AI agent deployments. All features are opt-in -- existing workflows are unaffected until you explicitly enable a feature: - **Content Boundary Markers** -- Wrap page output in delimiters so LLMs can distinguish tool output from untrusted content: `--content-boundaries` -- **Domain Allowlist** -- Restrict navigation to trusted domains (wildcards like `*.example.com` also match the bare domain). Set via `allowedDomains` in `agent-browser.json` (config-file-only, cannot be overridden by CLI flags or env vars). Sub-resource requests (scripts, images, fetch) and WebSocket/EventSource connections to non-allowed domains are also blocked. +- **Domain Allowlist** -- Restrict where the agent can navigate and what resources pages can load. Set via config file only (`agent-browser.json`). Three controls are available: + - `allowedDomains` -- Restricts both navigation and sub-resources (legacy, still supported) + - `navigationDomains` -- Restricts only agent-initiated navigation (open, click, form submit) + - `resourceDomains` -- Restricts only page-initiated sub-resources (fetch, XHR, scripts, WebSocket) + + When `navigationDomains` or `resourceDomains` is set, it takes priority over `allowedDomains` for that scope. This lets you lock navigation to your app while allowing the page to load its own dependencies: + ```json + { + "navigationDomains": ["myapp.com", "*.myapp.com"], + "resourceDomains": ["*"] + } + ``` - **Action Policy** -- Gate destructive actions with a static policy file. Set via `actionPolicy` in `agent-browser.json` (config-file-only, cannot be overridden by CLI flags or env vars). - **Action Confirmation** -- Require explicit approval for sensitive action categories: `--confirm-actions download` - **Output Length Limits** -- Prevent context flooding: `--max-output 50000` @@ -544,7 +555,7 @@ agent-browser --config ./ci-config.json open example.com AGENT_BROWSER_CONFIG=./ci-config.json agent-browser open example.com ``` -All options from the table above can be set in the config file using camelCase keys (e.g., `--proxy-bypass` becomes `"proxyBypass"`). Unknown keys are ignored for forward compatibility. Note: `allowedDomains` and `actionPolicy` can only be set via config file (not CLI flags or env vars). +All options from the table above can be set in the config file using camelCase keys (e.g., `--proxy-bypass` becomes `"proxyBypass"`). Unknown keys are ignored for forward compatibility. Note: `allowedDomains`, `navigationDomains`, `resourceDomains`, and `actionPolicy` can only be set via config file (not CLI flags or env vars). Boolean flags accept an optional `true`/`false` value to override config settings. For example, `--headed false` disables `"headed": true` from config. A bare `--headed` is equivalent to `--headed true`. diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 11b67cbd8..3e36a1c05 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -1993,6 +1993,8 @@ mod tests { content_boundaries: false, max_output: None, allowed_domains: None, + navigation_domains: None, + resource_domains: None, action_policy: None, confirm_actions: None, confirm_interactive: false, diff --git a/cli/src/connection.rs b/cli/src/connection.rs index dc4284b96..6fb974973 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -209,6 +209,8 @@ pub struct DaemonOptions<'a> { pub session_name: Option<&'a str>, pub download_path: Option<&'a str>, pub allowed_domains: Option<&'a [String]>, + pub navigation_domains: Option<&'a [String]>, + pub resource_domains: Option<&'a [String]>, pub action_policy: Option<&'a str>, pub confirm_actions: Option<&'a str>, pub engine: Option<&'a str>, @@ -265,6 +267,12 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) { if let Some(ad) = opts.allowed_domains { cmd.env("AGENT_BROWSER_ALLOWED_DOMAINS", ad.join(",")); } + if let Some(nd) = opts.navigation_domains { + cmd.env("AGENT_BROWSER_NAVIGATION_DOMAINS", nd.join(",")); + } + if let Some(rd) = opts.resource_domains { + cmd.env("AGENT_BROWSER_RESOURCE_DOMAINS", rd.join(",")); + } if let Some(ap) = opts.action_policy { cmd.env("AGENT_BROWSER_ACTION_POLICY", ap); } diff --git a/cli/src/flags.rs b/cli/src/flags.rs index 1ef4a4dda..417278cff 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -79,6 +79,8 @@ pub struct Config { pub content_boundaries: Option, pub max_output: Option, pub allowed_domains: Option>, + pub navigation_domains: Option>, + pub resource_domains: Option>, pub action_policy: Option, pub confirm_actions: Option, pub confirm_interactive: Option, @@ -125,6 +127,8 @@ impl Config { content_boundaries: other.content_boundaries.or(self.content_boundaries), max_output: other.max_output.or(self.max_output), allowed_domains: other.allowed_domains.or(self.allowed_domains), + navigation_domains: other.navigation_domains.or(self.navigation_domains), + resource_domains: other.resource_domains.or(self.resource_domains), action_policy: other.action_policy.or(self.action_policy), confirm_actions: other.confirm_actions.or(self.confirm_actions), confirm_interactive: other.confirm_interactive.or(self.confirm_interactive), @@ -286,6 +290,8 @@ pub struct Flags { pub content_boundaries: bool, pub max_output: Option, pub allowed_domains: Option>, + pub navigation_domains: Option>, + pub resource_domains: Option>, pub action_policy: Option, pub confirm_actions: Option, pub confirm_interactive: bool, @@ -364,6 +370,8 @@ pub fn parse_flags(args: &[String]) -> Flags { .and_then(|s| s.parse().ok()) .or(config.max_output), allowed_domains: config.allowed_domains, + navigation_domains: config.navigation_domains, + resource_domains: config.resource_domains, action_policy: config.action_policy, confirm_actions: env::var("AGENT_BROWSER_CONFIRM_ACTIONS") .ok() diff --git a/cli/src/main.rs b/cli/src/main.rs index c29420ca2..3fc3606ce 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -488,6 +488,8 @@ fn main() { session_name: flags.session_name.as_deref(), download_path: flags.download_path.as_deref(), allowed_domains: flags.allowed_domains.as_deref(), + navigation_domains: flags.navigation_domains.as_deref(), + resource_domains: flags.resource_domains.as_deref(), action_policy: flags.action_policy.as_deref(), confirm_actions: flags.confirm_actions.as_deref(), engine: flags.engine.as_deref(), @@ -629,7 +631,13 @@ fn main() { } if let Some(ref domains) = flags.allowed_domains { - launch_cmd["allowedDomains"] = json!(domains); + launch_cmd["allowedDomains"] = json!(domains.join(",")); + } + if let Some(ref domains) = flags.navigation_domains { + launch_cmd["navigationDomains"] = json!(domains.join(",")); + } + if let Some(ref domains) = flags.resource_domains { + launch_cmd["resourceDomains"] = json!(domains.join(",")); } if let Some(ref engine) = flags.engine { diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index aeb4162a3..7358fe78d 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -221,12 +221,20 @@ impl DaemonState { webdriver_backend: None, backend_type: BackendType::Cdp, ref_map: RefMap::new(), - domain_filter: Arc::new(RwLock::new( - env::var("AGENT_BROWSER_ALLOWED_DOMAINS") - .ok() - .filter(|s| !s.is_empty()) - .map(|s| DomainFilter::new(&s)), - )), + domain_filter: Arc::new(RwLock::new({ + let allowed = env::var("AGENT_BROWSER_ALLOWED_DOMAINS").ok().filter(|s| !s.is_empty()); + let navigation = env::var("AGENT_BROWSER_NAVIGATION_DOMAINS").ok().filter(|s| !s.is_empty()); + let resource = env::var("AGENT_BROWSER_RESOURCE_DOMAINS").ok().filter(|s| !s.is_empty()); + if allowed.is_some() || navigation.is_some() || resource.is_some() { + Some(DomainFilter::with_split( + allowed.as_deref().unwrap_or(""), + navigation.as_deref(), + resource.as_deref(), + )) + } else { + None + } + })), event_tracker: EventTracker::new(), session_name: env::var("AGENT_BROWSER_SESSION_NAME").ok(), session_id: env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()), @@ -581,7 +589,7 @@ impl DaemonState { let _ = network::install_domain_filter( &mgr.client, &attach.session_id, - &filter.allowed_domains, + filter, has_proxy_creds, ) .await; @@ -1696,13 +1704,22 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result Result Result

Blocked

Navigation to {} is not allowed by domain filter.

", hostname @@ -6899,10 +6922,14 @@ mod tests { async fn test_daemon_state_new() { let guard = EnvGuard::new(&[ "AGENT_BROWSER_ALLOWED_DOMAINS", + "AGENT_BROWSER_NAVIGATION_DOMAINS", + "AGENT_BROWSER_RESOURCE_DOMAINS", "AGENT_BROWSER_SESSION_NAME", "AGENT_BROWSER_SESSION", ]); guard.remove("AGENT_BROWSER_ALLOWED_DOMAINS"); + guard.remove("AGENT_BROWSER_NAVIGATION_DOMAINS"); + guard.remove("AGENT_BROWSER_RESOURCE_DOMAINS"); guard.remove("AGENT_BROWSER_SESSION_NAME"); guard.remove("AGENT_BROWSER_SESSION"); diff --git a/cli/src/native/network.rs b/cli/src/native/network.rs index 39c69df38..b959427d5 100644 --- a/cli/src/native/network.rs +++ b/cli/src/native/network.rs @@ -78,6 +78,15 @@ pub async fn set_content(client: &CdpClient, session_id: &str, html: &str) -> Re #[derive(Debug, Clone)] pub struct DomainFilter { + /// Domains the agent is allowed to navigate to (open, click, form submit). + /// When empty, all navigation is allowed. + pub navigation_domains: Vec, + /// Domains the page is allowed to load sub-resources from (fetch, XHR, + /// images, scripts, WebSocket, etc.). When empty, all resources are allowed. + pub resource_domains: Vec, + /// Legacy unified list. Kept for backwards compatibility: if + /// `navigation_domains` or `resource_domains` is empty, the corresponding + /// check falls back to this list. pub allowed_domains: Vec, } @@ -85,16 +94,59 @@ impl DomainFilter { pub fn new(domains: &str) -> Self { let allowed = parse_domain_list(domains); Self { + navigation_domains: Vec::new(), + resource_domains: Vec::new(), allowed_domains: allowed, } } - pub fn is_allowed(&self, hostname: &str) -> bool { - if self.allowed_domains.is_empty() { + pub fn with_split( + allowed: &str, + navigation: Option<&str>, + resource: Option<&str>, + ) -> Self { + Self { + allowed_domains: parse_domain_list(allowed), + navigation_domains: navigation + .map(|s| parse_domain_list(s)) + .unwrap_or_default(), + resource_domains: resource + .map(|s| parse_domain_list(s)) + .unwrap_or_default(), + } + } + + /// Check whether any filtering is active at all. + pub fn is_active(&self) -> bool { + !self.allowed_domains.is_empty() + || !self.navigation_domains.is_empty() + || !self.resource_domains.is_empty() + } + + /// Returns the effective domain list for navigation checks. + fn effective_navigation_domains(&self) -> &[String] { + if !self.navigation_domains.is_empty() { + &self.navigation_domains + } else { + &self.allowed_domains + } + } + + /// Returns the effective domain list for resource checks. + fn effective_resource_domains(&self) -> &[String] { + if !self.resource_domains.is_empty() { + &self.resource_domains + } else { + &self.allowed_domains + } + } + + fn matches_domain_list(domains: &[String], hostname: &str) -> bool { + if domains.is_empty() { return true; } let hostname = hostname.to_lowercase(); - for pattern in &self.allowed_domains { + for pattern in domains { if let Some(suffix) = pattern.strip_prefix("*.") { if hostname == suffix || hostname.ends_with(&format!(".{}", suffix)) { return true; @@ -106,6 +158,42 @@ impl DomainFilter { false } + /// Check if a hostname is allowed for agent-initiated navigation. + pub fn is_navigation_allowed(&self, hostname: &str) -> bool { + Self::matches_domain_list(self.effective_navigation_domains(), hostname) + } + + /// Check if a hostname is allowed for page-initiated sub-resource requests. + pub fn is_resource_allowed(&self, hostname: &str) -> bool { + Self::matches_domain_list(self.effective_resource_domains(), hostname) + } + + /// Legacy: check if a hostname is allowed (uses `allowed_domains`). + pub fn is_allowed(&self, hostname: &str) -> bool { + Self::matches_domain_list(&self.allowed_domains, hostname) + } + + /// Check a URL against the navigation domain filter. + pub fn check_navigation_url(&self, url: &str) -> Result<(), String> { + let domains = self.effective_navigation_domains(); + if domains.is_empty() { + return Ok(()); + } + let parsed = url::Url::parse(url).map_err(|_| format!("Invalid URL: {}", url))?; + let hostname = parsed + .host_str() + .ok_or_else(|| format!("No hostname in URL: {}", url))?; + if Self::matches_domain_list(domains, hostname) { + Ok(()) + } else { + Err(format!( + "Domain '{}' is not in the allowed navigation domains list", + hostname + )) + } + } + + /// Legacy check_url for backwards compatibility (uses allowed_domains). pub fn check_url(&self, url: &str) -> Result<(), String> { if self.allowed_domains.is_empty() { return Ok(()); @@ -144,7 +232,7 @@ pub async fn sanitize_existing_pages( } if let Ok(parsed) = url::Url::parse(&page.url) { if let Some(hostname) = parsed.host_str() { - if !filter.is_allowed(hostname) { + if !filter.is_navigation_allowed(hostname) { let _ = client .send_command( "Page.navigate", @@ -161,13 +249,14 @@ pub async fn sanitize_existing_pages( pub async fn install_domain_filter_script( client: &CdpClient, session_id: &str, - allowed_domains: &[String], + filter: &DomainFilter, ) -> Result<(), String> { - if allowed_domains.is_empty() { + let resource_domains = filter.effective_resource_domains(); + if resource_domains.is_empty() { return Ok(()); } - let domains_json = serde_json::to_string(allowed_domains).unwrap_or("[]".to_string()); + let domains_json = serde_json::to_string(resource_domains).unwrap_or("[]".to_string()); let script = format!( r#"(() => {{ const _allowed = {}; @@ -253,10 +342,10 @@ pub async fn install_domain_filter_fetch( pub async fn install_domain_filter( client: &CdpClient, session_id: &str, - allowed_domains: &[String], + filter: &DomainFilter, handle_auth_requests: bool, ) -> Result<(), String> { - install_domain_filter_script(client, session_id, allowed_domains).await?; + install_domain_filter_script(client, session_id, filter).await?; install_domain_filter_fetch(client, session_id, handle_auth_requests).await?; Ok(()) } @@ -493,6 +582,87 @@ mod tests { assert_eq!(domains, vec!["a.com", "b.com", "*.c.com"]); } + #[test] + fn test_split_filter_navigation_only() { + let filter = DomainFilter::with_split("", Some("myapp.com"), None); + // Navigation restricted to myapp.com + assert!(filter.is_navigation_allowed("myapp.com")); + assert!(!filter.is_navigation_allowed("evil.com")); + // Resources unrestricted (no resource_domains, no allowed_domains) + assert!(filter.is_resource_allowed("anything.com")); + assert!(filter.is_resource_allowed("cdn.example.com")); + } + + #[test] + fn test_split_filter_resource_only() { + let filter = DomainFilter::with_split("", None, Some("cdn.example.com")); + // Navigation unrestricted (no navigation_domains, no allowed_domains) + assert!(filter.is_navigation_allowed("anywhere.com")); + // Resources restricted to cdn.example.com + assert!(filter.is_resource_allowed("cdn.example.com")); + assert!(!filter.is_resource_allowed("other.com")); + } + + #[test] + fn test_split_filter_both() { + let filter = DomainFilter::with_split( + "", + Some("myapp.com, *.myapp.com"), + Some("*.cdn.net, *.api.io"), + ); + // Navigation: only myapp.com + assert!(filter.is_navigation_allowed("myapp.com")); + assert!(filter.is_navigation_allowed("sub.myapp.com")); + assert!(!filter.is_navigation_allowed("evil.com")); + // Resources: only cdn.net and api.io + assert!(filter.is_resource_allowed("img.cdn.net")); + assert!(filter.is_resource_allowed("v1.api.io")); + assert!(!filter.is_resource_allowed("evil.com")); + } + + #[test] + fn test_split_filter_fallback_to_allowed_domains() { + // When split fields are empty, falls back to allowed_domains + let filter = DomainFilter::with_split("example.com, *.example.com", None, None); + assert!(filter.is_navigation_allowed("example.com")); + assert!(!filter.is_navigation_allowed("other.com")); + assert!(filter.is_resource_allowed("sub.example.com")); + assert!(!filter.is_resource_allowed("other.com")); + } + + #[test] + fn test_split_filter_navigation_overrides_allowed() { + // navigation_domains takes priority over allowed_domains for navigation + let filter = DomainFilter::with_split( + "legacy.com", + Some("myapp.com"), + None, + ); + // Navigation uses navigation_domains, not allowed_domains + assert!(filter.is_navigation_allowed("myapp.com")); + assert!(!filter.is_navigation_allowed("legacy.com")); + // Resources fall back to allowed_domains + assert!(filter.is_resource_allowed("legacy.com")); + assert!(!filter.is_resource_allowed("other.com")); + } + + #[test] + fn test_split_filter_check_navigation_url() { + let filter = DomainFilter::with_split("", Some("myapp.com"), None); + assert!(filter.check_navigation_url("https://myapp.com/page").is_ok()); + assert!(filter.check_navigation_url("https://evil.com/page").is_err()); + // Resources still unrestricted + assert!(filter.is_resource_allowed("evil.com")); + } + + #[test] + fn test_split_filter_is_active() { + assert!(!DomainFilter::with_split("", None, None).is_active()); + assert!(DomainFilter::with_split("example.com", None, None).is_active()); + assert!(DomainFilter::with_split("", Some("example.com"), None).is_active()); + assert!(DomainFilter::with_split("", None, Some("example.com")).is_active()); + } + #[test] fn test_event_tracker() { let mut tracker = EventTracker::new(); diff --git a/cli/src/output.rs b/cli/src/output.rs index a6e353c31..85ca7e14d 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -2596,7 +2596,9 @@ Options: --download-path Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH) --content-boundaries Wrap page output in boundary markers (or AGENT_BROWSER_CONTENT_BOUNDARIES) --max-output Truncate page output to N chars (or AGENT_BROWSER_MAX_OUTPUT) - --allowed-domains Restrict navigation domains (or AGENT_BROWSER_ALLOWED_DOMAINS) + --allowed-domains Restrict navigation and resource domains (or AGENT_BROWSER_ALLOWED_DOMAINS) + --navigation-domains Restrict agent navigation only (or AGENT_BROWSER_NAVIGATION_DOMAINS) + --resource-domains Restrict page sub-resources only (or AGENT_BROWSER_RESOURCE_DOMAINS) --action-policy Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY) --confirm-actions Categories requiring confirmation (or AGENT_BROWSER_CONFIRM_ACTIONS) --confirm-interactive Interactive confirmation prompts; auto-denies if stdin is not a TTY (or AGENT_BROWSER_CONFIRM_INTERACTIVE) @@ -2653,7 +2655,9 @@ Environment: AGENT_BROWSER_IOS_UDID Default iOS device UDID AGENT_BROWSER_CONTENT_BOUNDARIES Wrap page output in boundary markers AGENT_BROWSER_MAX_OUTPUT Max characters for page output - AGENT_BROWSER_ALLOWED_DOMAINS Comma-separated allowed domain patterns + AGENT_BROWSER_ALLOWED_DOMAINS Comma-separated allowed domain patterns (navigation + resources) + AGENT_BROWSER_NAVIGATION_DOMAINS Comma-separated navigation-only domain patterns + AGENT_BROWSER_RESOURCE_DOMAINS Comma-separated resource-only domain patterns AGENT_BROWSER_ACTION_POLICY Path to action policy JSON file AGENT_BROWSER_CONFIRM_ACTIONS Action categories requiring confirmation AGENT_BROWSER_CONFIRM_INTERACTIVE Enable interactive confirmation prompts diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index 5ed15e1fa..c4c1c2f5a 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -357,7 +357,24 @@ agent-browser snapshot ### Domain Allowlist -Restrict navigation to trusted domains. Wildcards like `*.example.com` also match the bare domain `example.com`. Sub-resource requests, WebSocket, and EventSource connections to non-allowed domains are also blocked. Include CDN domains your target pages depend on: +Three config-file-only controls restrict where the browser can connect: + +- `allowedDomains` -- Restricts both navigation and sub-resources (legacy unified list) +- `navigationDomains` -- Restricts only agent-initiated navigation (open, click, form submit) +- `resourceDomains` -- Restricts only page-initiated sub-resources (fetch, XHR, scripts, WebSocket) + +When `navigationDomains` or `resourceDomains` is set, it takes priority over `allowedDomains` for that scope. Wildcards like `*.example.com` also match the bare domain. + +To lock navigation to your app while allowing pages to load their own dependencies: + +```json +{ + "navigationDomains": ["myapp.com", "*.myapp.com"], + "resourceDomains": ["*"] +} +``` + +Legacy usage (restricts both navigation and resources): ```bash export AGENT_BROWSER_ALLOWED_DOMAINS="example.com,*.example.com" @@ -543,7 +560,7 @@ Create `agent-browser.json` in the project root for persistent settings: } ``` -Priority (lowest to highest): `~/.agent-browser/config.json` < `./agent-browser.json` < env vars < CLI flags. Use `--config ` or `AGENT_BROWSER_CONFIG` env var for a custom config file (exits with error if missing/invalid). All CLI options map to camelCase keys (e.g., `--proxy` -> `"proxy"`). Boolean flags accept `true`/`false` values (e.g., `--headed false` overrides config). Note: `allowedDomains` and `actionPolicy` can only be set via config file, not CLI flags. +Priority (lowest to highest): `~/.agent-browser/config.json` < `./agent-browser.json` < env vars < CLI flags. Use `--config ` or `AGENT_BROWSER_CONFIG` env var for a custom config file (exits with error if missing/invalid). All CLI options map to camelCase keys (e.g., `--proxy` -> `"proxy"`). Boolean flags accept `true`/`false` values (e.g., `--headed false` overrides config). Note: `allowedDomains`, `navigationDomains`, `resourceDomains`, and `actionPolicy` can only be set via config file, not CLI flags. ## Deep-Dive Documentation From 1364c7414ca3d2f53ae1010db286c785b3d3c8b3 Mon Sep 17 00:00:00 2001 From: Ariel Rahmane Date: Thu, 2 Apr 2026 10:45:29 +1100 Subject: [PATCH 2/4] feat: make timeout 5 minutes by default instead of disabled --- README.md | 2 +- cli/src/native/daemon.rs | 10 +++++----- cli/src/output.rs | 2 +- skills/agent-browser/SKILL.md | 9 ++++++++- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f7b165644..2f5a75815 100644 --- a/README.md +++ b/README.md @@ -730,7 +730,7 @@ agent-browser uses a client-daemon architecture: 1. **Rust CLI** - Parses commands, communicates with daemon 2. **Rust Daemon** - Pure Rust daemon using direct CDP, no Node.js required -The daemon starts automatically on first command and persists between commands for fast subsequent operations. To auto-shutdown the daemon after a period of inactivity, set `AGENT_BROWSER_IDLE_TIMEOUT_MS` (value in milliseconds). When set, the daemon closes the browser and exits after receiving no commands for the specified duration. +The daemon starts automatically on first command and persists between commands for fast subsequent operations. By default, the daemon auto-shuts down after **5 minutes** of inactivity (no commands received). To customize this, set `AGENT_BROWSER_IDLE_TIMEOUT_MS` (value in milliseconds) or use `--idle-timeout` (supports `10s`, `3m`, `1h` units). Set to `0` to disable the timeout and keep the daemon running indefinitely. **Browser Engine:** Uses Chrome (from Chrome for Testing) by default. The `--engine` flag selects between `chrome` and `lightpanda`. Supported browsers: Chromium/Chrome (via CDP) and Safari (via WebDriver for iOS). diff --git a/cli/src/native/daemon.rs b/cli/src/native/daemon.rs index 90a78a1e2..88681ac64 100644 --- a/cli/src/native/daemon.rs +++ b/cli/src/native/daemon.rs @@ -94,11 +94,11 @@ pub async fn run_daemon(session: &str) { } // Auto-shutdown the daemon after this many ms of inactivity (no commands received). - // Disabled when unset or 0. - let idle_timeout_ms = env::var("AGENT_BROWSER_IDLE_TIMEOUT_MS") - .ok() - .and_then(|s| s.parse::().ok()) - .filter(|&ms| ms > 0); + // Default: 5 minutes. Explicitly set to "0" to disable. + let idle_timeout_ms = match env::var("AGENT_BROWSER_IDLE_TIMEOUT_MS") { + Ok(s) => s.parse::().ok().filter(|&ms| ms > 0), // explicit 0 = disabled + Err(_) => Some(300_000), // default 5 min + }; let result = run_socket_server( &socket_path, diff --git a/cli/src/output.rs b/cli/src/output.rs index 85ca7e14d..a5d203ae2 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -2650,7 +2650,7 @@ Environment: AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete saved states older than N days (default: 30) AGENT_BROWSER_ENCRYPTION_KEY 64-char hex key for AES-256-GCM session encryption AGENT_BROWSER_STREAM_PORT Override WebSocket streaming port (default: OS-assigned) - AGENT_BROWSER_IDLE_TIMEOUT_MS Auto-shutdown daemon after N ms of inactivity (disabled by default) + AGENT_BROWSER_IDLE_TIMEOUT_MS Auto-shutdown daemon after N ms of inactivity (default: 300000 / 5min, 0 to disable) AGENT_BROWSER_IOS_DEVICE Default iOS device name AGENT_BROWSER_IOS_UDID Default iOS device UDID AGENT_BROWSER_CONTENT_BOUNDARIES Wrap page output in boundary markers diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index c4c1c2f5a..369434430 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -496,10 +496,17 @@ agent-browser close --all # Close all active sessions If a previous session was not closed properly, the daemon may still be running. Use `agent-browser close` to clean it up, or `agent-browser close --all` to shut down every session at once. -To auto-shutdown the daemon after a period of inactivity (useful for ephemeral/CI environments): +The daemon auto-shuts down after **5 minutes** of inactivity by default. To customize: ```bash +# Set a 1-minute timeout +agent-browser --idle-timeout 1m open example.com + +# Or via environment variable (in milliseconds) AGENT_BROWSER_IDLE_TIMEOUT_MS=60000 agent-browser open example.com + +# Disable auto-shutdown (daemon runs until explicitly closed) +AGENT_BROWSER_IDLE_TIMEOUT_MS=0 agent-browser open example.com ``` ## Ref Lifecycle (Important) From 5d46ac031cc78ae8baf73220c3cd593b85825445 Mon Sep 17 00:00:00 2001 From: Ariel Rahmane Date: Thu, 2 Apr 2026 11:37:02 +1100 Subject: [PATCH 3/4] fix: remove env var and CLI flag overrides for domain filtering navigationDomains and resourceDomains must only come from the config file (operator-controlled). Allowing env vars or CLI flags would let the agent bypass domain restrictions, defeating the security purpose. - Remove AGENT_BROWSER_NAVIGATION_DOMAINS and AGENT_BROWSER_RESOURCE_DOMAINS env vars - Remove --navigation-domains and --resource-domains CLI flags from help text - Remove env var reading from DaemonState::new() (launch command JSON is the only path) - Fix docs: remove resourceDomains: ["*"] examples (omit resourceDomains to allow all) Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 6 +++--- cli/src/connection.rs | 8 -------- cli/src/main.rs | 2 -- cli/src/native/actions.rs | 14 ++------------ cli/src/output.rs | 4 ---- skills/agent-browser/SKILL.md | 5 ++--- 6 files changed, 7 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 2f5a75815..f50df6b58 100644 --- a/README.md +++ b/README.md @@ -432,13 +432,13 @@ agent-browser includes security features for safe AI agent deployments. All feat - `navigationDomains` -- Restricts only agent-initiated navigation (open, click, form submit) - `resourceDomains` -- Restricts only page-initiated sub-resources (fetch, XHR, scripts, WebSocket) - When `navigationDomains` or `resourceDomains` is set, it takes priority over `allowedDomains` for that scope. This lets you lock navigation to your app while allowing the page to load its own dependencies: + When `navigationDomains` or `resourceDomains` is set, it takes priority over `allowedDomains` for that scope. Omitting `resourceDomains` leaves sub-resources unrestricted, so you can lock navigation to your app while allowing pages to load their own dependencies: ```json { - "navigationDomains": ["myapp.com", "*.myapp.com"], - "resourceDomains": ["*"] + "navigationDomains": ["myapp.com", "*.myapp.com"] } ``` + These controls can only be set via the config file — not via CLI flags or environment variables — so the agent cannot override them. - **Action Policy** -- Gate destructive actions with a static policy file. Set via `actionPolicy` in `agent-browser.json` (config-file-only, cannot be overridden by CLI flags or env vars). - **Action Confirmation** -- Require explicit approval for sensitive action categories: `--confirm-actions download` - **Output Length Limits** -- Prevent context flooding: `--max-output 50000` diff --git a/cli/src/connection.rs b/cli/src/connection.rs index 6fb974973..dc4284b96 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -209,8 +209,6 @@ pub struct DaemonOptions<'a> { pub session_name: Option<&'a str>, pub download_path: Option<&'a str>, pub allowed_domains: Option<&'a [String]>, - pub navigation_domains: Option<&'a [String]>, - pub resource_domains: Option<&'a [String]>, pub action_policy: Option<&'a str>, pub confirm_actions: Option<&'a str>, pub engine: Option<&'a str>, @@ -267,12 +265,6 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) { if let Some(ad) = opts.allowed_domains { cmd.env("AGENT_BROWSER_ALLOWED_DOMAINS", ad.join(",")); } - if let Some(nd) = opts.navigation_domains { - cmd.env("AGENT_BROWSER_NAVIGATION_DOMAINS", nd.join(",")); - } - if let Some(rd) = opts.resource_domains { - cmd.env("AGENT_BROWSER_RESOURCE_DOMAINS", rd.join(",")); - } if let Some(ap) = opts.action_policy { cmd.env("AGENT_BROWSER_ACTION_POLICY", ap); } diff --git a/cli/src/main.rs b/cli/src/main.rs index 3fc3606ce..eb75666c2 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -488,8 +488,6 @@ fn main() { session_name: flags.session_name.as_deref(), download_path: flags.download_path.as_deref(), allowed_domains: flags.allowed_domains.as_deref(), - navigation_domains: flags.navigation_domains.as_deref(), - resource_domains: flags.resource_domains.as_deref(), action_policy: flags.action_policy.as_deref(), confirm_actions: flags.confirm_actions.as_deref(), engine: flags.engine.as_deref(), diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 7358fe78d..7be96f83e 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -223,14 +223,8 @@ impl DaemonState { ref_map: RefMap::new(), domain_filter: Arc::new(RwLock::new({ let allowed = env::var("AGENT_BROWSER_ALLOWED_DOMAINS").ok().filter(|s| !s.is_empty()); - let navigation = env::var("AGENT_BROWSER_NAVIGATION_DOMAINS").ok().filter(|s| !s.is_empty()); - let resource = env::var("AGENT_BROWSER_RESOURCE_DOMAINS").ok().filter(|s| !s.is_empty()); - if allowed.is_some() || navigation.is_some() || resource.is_some() { - Some(DomainFilter::with_split( - allowed.as_deref().unwrap_or(""), - navigation.as_deref(), - resource.as_deref(), - )) + if let Some(ref domains) = allowed { + Some(DomainFilter::with_split(domains, None, None)) } else { None } @@ -6922,14 +6916,10 @@ mod tests { async fn test_daemon_state_new() { let guard = EnvGuard::new(&[ "AGENT_BROWSER_ALLOWED_DOMAINS", - "AGENT_BROWSER_NAVIGATION_DOMAINS", - "AGENT_BROWSER_RESOURCE_DOMAINS", "AGENT_BROWSER_SESSION_NAME", "AGENT_BROWSER_SESSION", ]); guard.remove("AGENT_BROWSER_ALLOWED_DOMAINS"); - guard.remove("AGENT_BROWSER_NAVIGATION_DOMAINS"); - guard.remove("AGENT_BROWSER_RESOURCE_DOMAINS"); guard.remove("AGENT_BROWSER_SESSION_NAME"); guard.remove("AGENT_BROWSER_SESSION"); diff --git a/cli/src/output.rs b/cli/src/output.rs index a5d203ae2..927b8f637 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -2597,8 +2597,6 @@ Options: --content-boundaries Wrap page output in boundary markers (or AGENT_BROWSER_CONTENT_BOUNDARIES) --max-output Truncate page output to N chars (or AGENT_BROWSER_MAX_OUTPUT) --allowed-domains Restrict navigation and resource domains (or AGENT_BROWSER_ALLOWED_DOMAINS) - --navigation-domains Restrict agent navigation only (or AGENT_BROWSER_NAVIGATION_DOMAINS) - --resource-domains Restrict page sub-resources only (or AGENT_BROWSER_RESOURCE_DOMAINS) --action-policy Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY) --confirm-actions Categories requiring confirmation (or AGENT_BROWSER_CONFIRM_ACTIONS) --confirm-interactive Interactive confirmation prompts; auto-denies if stdin is not a TTY (or AGENT_BROWSER_CONFIRM_INTERACTIVE) @@ -2656,8 +2654,6 @@ Environment: AGENT_BROWSER_CONTENT_BOUNDARIES Wrap page output in boundary markers AGENT_BROWSER_MAX_OUTPUT Max characters for page output AGENT_BROWSER_ALLOWED_DOMAINS Comma-separated allowed domain patterns (navigation + resources) - AGENT_BROWSER_NAVIGATION_DOMAINS Comma-separated navigation-only domain patterns - AGENT_BROWSER_RESOURCE_DOMAINS Comma-separated resource-only domain patterns AGENT_BROWSER_ACTION_POLICY Path to action policy JSON file AGENT_BROWSER_CONFIRM_ACTIONS Action categories requiring confirmation AGENT_BROWSER_CONFIRM_INTERACTIVE Enable interactive confirmation prompts diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index 369434430..70e153aaa 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -363,14 +363,13 @@ Three config-file-only controls restrict where the browser can connect: - `navigationDomains` -- Restricts only agent-initiated navigation (open, click, form submit) - `resourceDomains` -- Restricts only page-initiated sub-resources (fetch, XHR, scripts, WebSocket) -When `navigationDomains` or `resourceDomains` is set, it takes priority over `allowedDomains` for that scope. Wildcards like `*.example.com` also match the bare domain. +When `navigationDomains` or `resourceDomains` is set, it takes priority over `allowedDomains` for that scope. Wildcards like `*.example.com` also match the bare domain. Omitting `resourceDomains` leaves sub-resources unrestricted. These controls can only be set via the config file — not via CLI flags or environment variables — so the agent cannot override them. To lock navigation to your app while allowing pages to load their own dependencies: ```json { - "navigationDomains": ["myapp.com", "*.myapp.com"], - "resourceDomains": ["*"] + "navigationDomains": ["myapp.com", "*.myapp.com"] } ``` From f9d8a8587035a89872891f1820206e26cf592c00 Mon Sep 17 00:00:00 2001 From: Ariel Rahmane Date: Thu, 2 Apr 2026 11:52:44 +1100 Subject: [PATCH 4/4] fix: lock allowedDomains to config-file-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same treatment as navigationDomains/resourceDomains — remove the AGENT_BROWSER_ALLOWED_DOMAINS env var pass-through and env var reading from the daemon. All domain filtering now flows exclusively through the config file via the launch command JSON, preventing the agent from overriding operator-set restrictions. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/connection.rs | 6 ++---- cli/src/main.rs | 1 - cli/src/native/actions.rs | 11 +---------- cli/src/output.rs | 3 +-- 4 files changed, 4 insertions(+), 17 deletions(-) diff --git a/cli/src/connection.rs b/cli/src/connection.rs index dc4284b96..bd8974824 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -208,7 +208,8 @@ pub struct DaemonOptions<'a> { pub device: Option<&'a str>, pub session_name: Option<&'a str>, pub download_path: Option<&'a str>, - pub allowed_domains: Option<&'a [String]>, + // Domain filtering is config-file-only (not passed via env vars for security). + // The config values flow through the launch command JSON instead. pub action_policy: Option<&'a str>, pub confirm_actions: Option<&'a str>, pub engine: Option<&'a str>, @@ -262,9 +263,6 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) { if let Some(dp) = opts.download_path { cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp); } - if let Some(ad) = opts.allowed_domains { - cmd.env("AGENT_BROWSER_ALLOWED_DOMAINS", ad.join(",")); - } if let Some(ap) = opts.action_policy { cmd.env("AGENT_BROWSER_ACTION_POLICY", ap); } diff --git a/cli/src/main.rs b/cli/src/main.rs index eb75666c2..c8ba6da3d 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -487,7 +487,6 @@ fn main() { device: flags.device.as_deref(), session_name: flags.session_name.as_deref(), download_path: flags.download_path.as_deref(), - allowed_domains: flags.allowed_domains.as_deref(), action_policy: flags.action_policy.as_deref(), confirm_actions: flags.confirm_actions.as_deref(), engine: flags.engine.as_deref(), diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 7be96f83e..ae12ae620 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -221,14 +221,7 @@ impl DaemonState { webdriver_backend: None, backend_type: BackendType::Cdp, ref_map: RefMap::new(), - domain_filter: Arc::new(RwLock::new({ - let allowed = env::var("AGENT_BROWSER_ALLOWED_DOMAINS").ok().filter(|s| !s.is_empty()); - if let Some(ref domains) = allowed { - Some(DomainFilter::with_split(domains, None, None)) - } else { - None - } - })), + domain_filter: Arc::new(RwLock::new(None)), event_tracker: EventTracker::new(), session_name: env::var("AGENT_BROWSER_SESSION_NAME").ok(), session_id: env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()), @@ -6915,11 +6908,9 @@ mod tests { #[tokio::test] async fn test_daemon_state_new() { let guard = EnvGuard::new(&[ - "AGENT_BROWSER_ALLOWED_DOMAINS", "AGENT_BROWSER_SESSION_NAME", "AGENT_BROWSER_SESSION", ]); - guard.remove("AGENT_BROWSER_ALLOWED_DOMAINS"); guard.remove("AGENT_BROWSER_SESSION_NAME"); guard.remove("AGENT_BROWSER_SESSION"); diff --git a/cli/src/output.rs b/cli/src/output.rs index 927b8f637..37cf2b2ee 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -2596,7 +2596,7 @@ Options: --download-path Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH) --content-boundaries Wrap page output in boundary markers (or AGENT_BROWSER_CONTENT_BOUNDARIES) --max-output Truncate page output to N chars (or AGENT_BROWSER_MAX_OUTPUT) - --allowed-domains Restrict navigation and resource domains (or AGENT_BROWSER_ALLOWED_DOMAINS) + --allowed-domains Restrict navigation and resource domains (config file only) --action-policy Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY) --confirm-actions Categories requiring confirmation (or AGENT_BROWSER_CONFIRM_ACTIONS) --confirm-interactive Interactive confirmation prompts; auto-denies if stdin is not a TTY (or AGENT_BROWSER_CONFIRM_INTERACTIVE) @@ -2653,7 +2653,6 @@ Environment: AGENT_BROWSER_IOS_UDID Default iOS device UDID AGENT_BROWSER_CONTENT_BOUNDARIES Wrap page output in boundary markers AGENT_BROWSER_MAX_OUTPUT Max characters for page output - AGENT_BROWSER_ALLOWED_DOMAINS Comma-separated allowed domain patterns (navigation + resources) AGENT_BROWSER_ACTION_POLICY Path to action policy JSON file AGENT_BROWSER_CONFIRM_ACTIONS Action categories requiring confirmation AGENT_BROWSER_CONFIRM_INTERACTIVE Enable interactive confirmation prompts