Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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"]
}
```
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`
Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -719,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).

Expand Down
2 changes: 2 additions & 0 deletions cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 2 additions & 4 deletions cli/src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
Expand Down Expand Up @@ -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);
}
Expand Down
8 changes: 8 additions & 0 deletions cli/src/flags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ pub struct Config {
pub content_boundaries: Option<bool>,
pub max_output: Option<usize>,
pub allowed_domains: Option<Vec<String>>,
pub navigation_domains: Option<Vec<String>>,
pub resource_domains: Option<Vec<String>>,
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: Option<bool>,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -286,6 +290,8 @@ pub struct Flags {
pub content_boundaries: bool,
pub max_output: Option<usize>,
pub allowed_domains: Option<Vec<String>>,
pub navigation_domains: Option<Vec<String>>,
pub resource_domains: Option<Vec<String>>,
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: bool,
Expand Down Expand Up @@ -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()
Expand Down
9 changes: 7 additions & 2 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -629,7 +628,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 {
Expand Down
46 changes: 27 additions & 19 deletions cli/src/native/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,12 +221,7 @@ 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(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()),
Expand Down Expand Up @@ -581,7 +576,7 @@ impl DaemonState {
let _ = network::install_domain_filter(
&mgr.client,
&attach.session_id,
&filter.allowed_domains,
filter,
has_proxy_creds,
)
.await;
Expand Down Expand Up @@ -1696,13 +1691,22 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
));
}

if let Some(ref domains) = cmd
.get("allowedDomains")
.and_then(|v| v.as_str())
.map(String::from)
{
let mut df = state.domain_filter.write().await;
*df = Some(DomainFilter::new(domains));
let allowed = cmd
.get("allowedDomains")
.and_then(|v| v.as_str())
.unwrap_or("");
let navigation = cmd
.get("navigationDomains")
.and_then(|v| v.as_str());
let resource = cmd
.get("resourceDomains")
.and_then(|v| v.as_str());
let filter = DomainFilter::with_split(allowed, navigation, resource);
if filter.is_active() {
let mut df = state.domain_filter.write().await;
*df = Some(filter);
}
}

state.engine = engine.as_deref().unwrap_or("chrome").to_string();
Expand All @@ -1728,7 +1732,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
let _ = network::install_domain_filter(
&mgr.client,
session_id,
&filter.allowed_domains,
filter,
has_proxy_auth,
)
.await;
Expand Down Expand Up @@ -1851,7 +1855,7 @@ async fn handle_navigate(cmd: &Value, state: &mut DaemonState) -> Result<Value,
{
let df = state.domain_filter.read().await;
if let Some(ref filter) = *df {
filter.check_url(url)?;
filter.check_navigation_url(url)?;
}
}

Expand Down Expand Up @@ -6004,8 +6008,14 @@ async fn resolve_fetch_paused(
}

if let Some(hostname) = parsed.host_str() {
if !filter.is_allowed(hostname) {
if paused.resource_type.eq_ignore_ascii_case("document") {
let is_document = paused.resource_type.eq_ignore_ascii_case("document");
let allowed = if is_document {
filter.is_navigation_allowed(hostname)
} else {
filter.is_resource_allowed(hostname)
};
if !allowed {
if is_document {
let error_body = format!(
"<html><body><h1>Blocked</h1><p>Navigation to {} is not allowed by domain filter.</p></body></html>",
hostname
Expand Down Expand Up @@ -6898,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");

Expand Down
10 changes: 5 additions & 5 deletions cli/src/native/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<u64>().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::<u64>().ok().filter(|&ms| ms > 0), // explicit 0 = disabled
Err(_) => Some(300_000), // default 5 min
};

let result = run_socket_server(
&socket_path,
Expand Down
Loading