diff --git a/README.md b/README.md index acf519b..662384a 100644 --- a/README.md +++ b/README.md @@ -395,6 +395,28 @@ agent-browser --session-name secure open example.com | `AGENT_BROWSER_ENCRYPTION_KEY` | 64-char hex key for AES-256-GCM encryption | | `AGENT_BROWSER_STATE_EXPIRE_DAYS` | Auto-delete states older than N days (default: 30) | +## Security + +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: + +- **Authentication Vault** -- Store credentials locally (always encrypted), reference by name. The LLM never sees passwords. A key is auto-generated at `~/.agent-browser/.encryption-key` if `AGENT_BROWSER_ENCRYPTION_KEY` is not set: `echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin` then `agent-browser auth login github` +- **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): `--allowed-domains "example.com,*.example.com"`. Sub-resource requests (scripts, images, fetch) and WebSocket/EventSource connections to non-allowed domains are also blocked. Include any CDN domains your target pages depend on (e.g., `*.cdn.example.com`). +- **Action Policy** -- Gate destructive actions with a static policy file: `--action-policy ./policy.json` +- **Action Confirmation** -- Require explicit approval for sensitive action categories: `--confirm-actions eval,download` +- **Output Length Limits** -- Prevent context flooding: `--max-output 50000` + +| Variable | Description | +|----------|-------------| +| `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_ACTION_POLICY` | Path to action policy JSON file | +| `AGENT_BROWSER_CONFIRM_ACTIONS` | Action categories requiring confirmation | +| `AGENT_BROWSER_CONFIRM_INTERACTIVE` | Enable interactive confirmation prompts | + +See [Security documentation](https://agent-browser.vercel.app/security) for details. + ## Snapshot Options The `snapshot` command supports filtering to reduce output size: @@ -467,6 +489,12 @@ This is useful for multimodal AI models that can reason about visual layout, unl | `--auto-connect` | Auto-discover and connect to running Chrome (or `AGENT_BROWSER_AUTO_CONNECT` env) | | `--color-scheme ` | Color scheme: `dark`, `light`, `no-preference` (or `AGENT_BROWSER_COLOR_SCHEME` env) | | `--download-path ` | Default download directory (or `AGENT_BROWSER_DOWNLOAD_PATH` env) | +| `--content-boundaries` | Wrap page output in boundary markers for LLM safety (or `AGENT_BROWSER_CONTENT_BOUNDARIES` env) | +| `--max-output ` | Truncate page output to N characters (or `AGENT_BROWSER_MAX_OUTPUT` env) | +| `--allowed-domains ` | Comma-separated allowed domain patterns (or `AGENT_BROWSER_ALLOWED_DOMAINS` env) | +| `--action-policy ` | Path to action policy JSON file (or `AGENT_BROWSER_ACTION_POLICY` env) | +| `--confirm-actions ` | Action categories requiring confirmation (or `AGENT_BROWSER_CONFIRM_ACTIONS` env) | +| `--confirm-interactive` | Interactive confirmation prompts; auto-denies if stdin is not a TTY (or `AGENT_BROWSER_CONFIRM_INTERACTIVE` env) | | `--config ` | Use a custom config file (or `AGENT_BROWSER_CONFIG` env) | | `--debug` | Debug output | diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 83c4361..c747bf7 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -8,6 +8,7 @@ version = "0.14.0" dependencies = [ "base64", "dirs", + "getrandom", "libc", "serde", "serde_json", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index c09da0e..9638764 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -10,6 +10,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" dirs = "5.0" base64 = "0.22" +getrandom = "0.2" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 21e0e80..3252c26 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -92,6 +92,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result { let url = rest.first().ok_or_else(|| ParseError::MissingArguments { context: cmd.to_string(), @@ -561,6 +562,131 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result Ok(json!({ "id": id, "action": "close" })), + // === Authentication Vault === + "auth" => { + let sub = rest.first().map(|s| s.as_ref()); + match sub { + Some("save") => { + let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments { + context: "auth save".to_string(), + usage: "agent-browser auth save --url --username --password ", + })?; + + let mut url = None; + let mut username = None; + let mut password = None; + let mut password_stdin = false; + let mut username_selector = None; + let mut password_selector = None; + let mut submit_selector = None; + + let mut j = 2; + while j < rest.len() { + match rest[j].as_ref() { + "--url" => { url = rest.get(j + 1).cloned(); j += 1; } + "--username" => { username = rest.get(j + 1).cloned(); j += 1; } + "--password" => { password = rest.get(j + 1).cloned(); j += 1; } + "--password-stdin" => { password_stdin = true; } + "--username-selector" => { username_selector = rest.get(j + 1).cloned(); j += 1; } + "--password-selector" => { password_selector = rest.get(j + 1).cloned(); j += 1; } + "--submit-selector" => { submit_selector = rest.get(j + 1).cloned(); j += 1; } + other => { + if other.starts_with("--") { + return Err(ParseError::InvalidValue { + message: format!("unknown flag '{}' for auth save", other), + usage: "agent-browser auth save --url --username --password ", + }); + } + } + } + j += 1; + } + + let url_val = url.ok_or_else(|| ParseError::MissingArguments { + context: "auth save".to_string(), + usage: "agent-browser auth save --url --username --password [--password-stdin]", + })?; + let user_val = username.ok_or_else(|| ParseError::MissingArguments { + context: "auth save".to_string(), + usage: "agent-browser auth save --url --username --password [--password-stdin]", + })?; + + if !password_stdin && password.is_none() { + return Err(ParseError::MissingArguments { + context: "auth save".to_string(), + usage: "agent-browser auth save --url --username --password [--password-stdin]", + }); + } + + let mut cmd = json!({ + "id": id, + "action": "auth_save", + "name": name, + "url": url_val, + "username": user_val, + }); + if password_stdin { + cmd["passwordStdin"] = json!(true); + } + if let Some(pass_val) = password { + cmd["password"] = json!(pass_val); + } + if let Some(us) = username_selector { + cmd["usernameSelector"] = json!(us); + } + if let Some(ps) = password_selector { + cmd["passwordSelector"] = json!(ps); + } + if let Some(ss) = submit_selector { + cmd["submitSelector"] = json!(ss); + } + Ok(cmd) + } + Some("login") => { + let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments { + context: "auth login".to_string(), + usage: "agent-browser auth login ", + })?; + Ok(json!({ "id": id, "action": "auth_login", "name": name })) + } + Some("list") => Ok(json!({ "id": id, "action": "auth_list" })), + Some("delete") | Some("remove") => { + let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments { + context: "auth delete".to_string(), + usage: "agent-browser auth delete ", + })?; + Ok(json!({ "id": id, "action": "auth_delete", "name": name })) + } + Some("show") => { + let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments { + context: "auth show".to_string(), + usage: "agent-browser auth show ", + })?; + Ok(json!({ "id": id, "action": "auth_show", "name": name })) + } + _ => Err(ParseError::UnknownSubcommand { + subcommand: sub.unwrap_or("(none)").to_string(), + valid_options: &["save", "login", "list", "delete", "show"], + }), + } + } + + // === Action Confirmation === + "confirm" => { + let cid = rest.first().ok_or_else(|| ParseError::MissingArguments { + context: "confirm".to_string(), + usage: "agent-browser confirm ", + })?; + Ok(json!({ "id": id, "action": "confirm", "confirmationId": cid })) + } + "deny" => { + let cid = rest.first().ok_or_else(|| ParseError::MissingArguments { + context: "deny".to_string(), + usage: "agent-browser deny ", + })?; + Ok(json!({ "id": id, "action": "deny", "confirmationId": cid })) + } + // === Connect (CDP) === "connect" => { let endpoint = rest.first().ok_or_else(|| ParseError::MissingArguments { @@ -1936,6 +2062,13 @@ mod tests { annotate: false, color_scheme: None, download_path: None, + content_boundaries: false, + max_output: None, + allowed_domains: None, + action_policy: None, + confirm_actions: None, + confirm_interactive: false, + } } diff --git a/cli/src/connection.rs b/cli/src/connection.rs index c08bf75..0079f85 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -203,24 +203,94 @@ pub struct DaemonResult { pub already_running: bool, } -#[allow(clippy::too_many_arguments)] +/// Options forwarded to the daemon process as environment variables. +/// Note: `confirm_interactive` is intentionally absent -- it is a CLI-side +/// UX concern (prompting the user on stdin) and not a daemon configuration. +/// The daemon only needs `confirm_actions` to gate action categories. +pub struct DaemonOptions<'a> { + pub headed: bool, + pub executable_path: Option<&'a str>, + pub extensions: &'a [String], + pub args: Option<&'a str>, + pub user_agent: Option<&'a str>, + pub proxy: Option<&'a str>, + pub proxy_bypass: Option<&'a str>, + pub ignore_https_errors: bool, + pub allow_file_access: bool, + pub profile: Option<&'a str>, + pub state: Option<&'a str>, + pub provider: Option<&'a str>, + pub device: Option<&'a str>, + pub session_name: Option<&'a str>, + pub download_path: Option<&'a str>, + pub allowed_domains: Option<&'a [String]>, + pub action_policy: Option<&'a str>, + pub confirm_actions: Option<&'a str>, +} + +fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) { + cmd.env("AGENT_BROWSER_DAEMON", "1") + .env("AGENT_BROWSER_SESSION", session); + + if opts.headed { + cmd.env("AGENT_BROWSER_HEADED", "1"); + } + if let Some(path) = opts.executable_path { + cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path); + } + if !opts.extensions.is_empty() { + cmd.env("AGENT_BROWSER_EXTENSIONS", opts.extensions.join(",")); + } + if let Some(a) = opts.args { + cmd.env("AGENT_BROWSER_ARGS", a); + } + if let Some(ua) = opts.user_agent { + cmd.env("AGENT_BROWSER_USER_AGENT", ua); + } + if let Some(p) = opts.proxy { + cmd.env("AGENT_BROWSER_PROXY", p); + } + if let Some(pb) = opts.proxy_bypass { + cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb); + } + if opts.ignore_https_errors { + cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1"); + } + if opts.allow_file_access { + cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1"); + } + if let Some(prof) = opts.profile { + cmd.env("AGENT_BROWSER_PROFILE", prof); + } + if let Some(st) = opts.state { + cmd.env("AGENT_BROWSER_STATE", st); + } + if let Some(p) = opts.provider { + cmd.env("AGENT_BROWSER_PROVIDER", p); + } + if let Some(d) = opts.device { + cmd.env("AGENT_BROWSER_IOS_DEVICE", d); + } + if let Some(sn) = opts.session_name { + cmd.env("AGENT_BROWSER_SESSION_NAME", sn); + } + 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); + } + if let Some(ca) = opts.confirm_actions { + cmd.env("AGENT_BROWSER_CONFIRM_ACTIONS", ca); + } +} + pub fn ensure_daemon( session: &str, - headed: bool, - executable_path: Option<&str>, - extensions: &[String], - args: Option<&str>, - user_agent: Option<&str>, - proxy: Option<&str>, - proxy_bypass: Option<&str>, - ignore_https_errors: bool, - allow_file_access: bool, - profile: Option<&str>, - state: Option<&str>, - provider: Option<&str>, - device: Option<&str>, - session_name: Option<&str>, - download_path: Option<&str>, + opts: &DaemonOptions, ) -> Result { // Check if daemon is running AND responsive if is_daemon_running(session) && daemon_ready(session) { @@ -305,69 +375,8 @@ pub fn ensure_daemon( use std::os::unix::process::CommandExt; let mut cmd = Command::new("node"); - cmd.arg(daemon_path) - .env("AGENT_BROWSER_DAEMON", "1") - .env("AGENT_BROWSER_SESSION", session); - - if headed { - cmd.env("AGENT_BROWSER_HEADED", "1"); - } - - if let Some(path) = executable_path { - cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path); - } - - if !extensions.is_empty() { - cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(",")); - } - - if let Some(a) = args { - cmd.env("AGENT_BROWSER_ARGS", a); - } - - if let Some(ua) = user_agent { - cmd.env("AGENT_BROWSER_USER_AGENT", ua); - } - - if let Some(p) = proxy { - cmd.env("AGENT_BROWSER_PROXY", p); - } - - if let Some(pb) = proxy_bypass { - cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb); - } - - if ignore_https_errors { - cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1"); - } - - if allow_file_access { - cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1"); - } - - if let Some(prof) = profile { - cmd.env("AGENT_BROWSER_PROFILE", prof); - } - - if let Some(st) = state { - cmd.env("AGENT_BROWSER_STATE", st); - } - - if let Some(p) = provider { - cmd.env("AGENT_BROWSER_PROVIDER", p); - } - - if let Some(d) = device { - cmd.env("AGENT_BROWSER_IOS_DEVICE", d); - } - - if let Some(sn) = session_name { - cmd.env("AGENT_BROWSER_SESSION_NAME", sn); - } - - if let Some(dp) = download_path { - cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp); - } + cmd.arg(daemon_path); + apply_daemon_env(&mut cmd, session, opts); // Create new process group and session to fully detach unsafe { @@ -392,69 +401,8 @@ pub fn ensure_daemon( // On Windows, call node directly. Command::new handles PATH resolution (node.exe or node.cmd) // and automatically quotes arguments containing spaces. let mut cmd = Command::new("node"); - cmd.arg(daemon_path) - .env("AGENT_BROWSER_DAEMON", "1") - .env("AGENT_BROWSER_SESSION", session); - - if headed { - cmd.env("AGENT_BROWSER_HEADED", "1"); - } - - if let Some(path) = executable_path { - cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path); - } - - if !extensions.is_empty() { - cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(",")); - } - - if let Some(a) = args { - cmd.env("AGENT_BROWSER_ARGS", a); - } - - if let Some(ua) = user_agent { - cmd.env("AGENT_BROWSER_USER_AGENT", ua); - } - - if let Some(p) = proxy { - cmd.env("AGENT_BROWSER_PROXY", p); - } - - if let Some(pb) = proxy_bypass { - cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb); - } - - if ignore_https_errors { - cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1"); - } - - if allow_file_access { - cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1"); - } - - if let Some(prof) = profile { - cmd.env("AGENT_BROWSER_PROFILE", prof); - } - - if let Some(st) = state { - cmd.env("AGENT_BROWSER_STATE", st); - } - - if let Some(p) = provider { - cmd.env("AGENT_BROWSER_PROVIDER", p); - } - - if let Some(d) = device { - cmd.env("AGENT_BROWSER_IOS_DEVICE", d); - } - - if let Some(sn) = session_name { - cmd.env("AGENT_BROWSER_SESSION_NAME", sn); - } - - if let Some(dp) = download_path { - cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp); - } + cmd.arg(daemon_path); + apply_daemon_env(&mut cmd, session, opts); // CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; diff --git a/cli/src/flags.rs b/cli/src/flags.rs index 1103ebe..87531a5 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -35,6 +35,12 @@ pub struct Config { pub annotate: Option, pub color_scheme: Option, pub download_path: Option, + pub content_boundaries: Option, + pub max_output: Option, + pub allowed_domains: Option>, + pub action_policy: Option, + pub confirm_actions: Option, + pub confirm_interactive: Option, } impl Config { @@ -70,6 +76,12 @@ impl Config { annotate: other.annotate.or(self.annotate), color_scheme: other.color_scheme.or(self.color_scheme), download_path: other.download_path.or(self.download_path), + 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), + 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), } } } @@ -116,6 +128,11 @@ fn parse_bool_arg(args: &[String], i: usize) -> (bool, bool) { /// Extract --config from args before full flag parsing. /// Returns `Some(Some(path))` if --config found, `Some(None)` if --config /// was the last arg with no value, `None` if --config not present. +/// +/// Only flags that consume a following argument need to be listed here. +/// Boolean flags (--content-boundaries, --confirm-interactive, etc.) are +/// intentionally absent -- they don't take a value, so they can't cause +/// the next argument to be mis-consumed. fn extract_config_path(args: &[String]) -> Option> { const FLAGS_WITH_VALUE: &[&str] = &[ "--session", @@ -135,6 +152,10 @@ fn extract_config_path(args: &[String]) -> Option> { "--session-name", "--color-scheme", "--download-path", + "--max-output", + "--allowed-domains", + "--action-policy", + "--confirm-actions", ]; let mut i = 0; while i < args.len() { @@ -207,6 +228,12 @@ pub struct Flags { pub annotate: bool, pub color_scheme: Option, pub download_path: Option, + pub content_boundaries: bool, + pub max_output: Option, + pub allowed_domains: Option>, + pub action_policy: Option, + pub confirm_actions: Option, + pub confirm_interactive: bool, // Track which launch-time options were explicitly passed via CLI // (as opposed to being set only via environment variables) @@ -292,6 +319,20 @@ pub fn parse_flags(args: &[String]) -> Flags { .or(config.color_scheme), download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok() .or(config.download_path), + content_boundaries: env_var_is_truthy("AGENT_BROWSER_CONTENT_BOUNDARIES") + || config.content_boundaries.unwrap_or(false), + max_output: env::var("AGENT_BROWSER_MAX_OUTPUT").ok() + .and_then(|s| s.parse().ok()) + .or(config.max_output), + allowed_domains: env::var("AGENT_BROWSER_ALLOWED_DOMAINS").ok() + .map(|s| s.split(',').map(|d| d.trim().to_lowercase()).filter(|d| !d.is_empty()).collect()) + .or(config.allowed_domains), + action_policy: env::var("AGENT_BROWSER_ACTION_POLICY").ok() + .or(config.action_policy), + confirm_actions: env::var("AGENT_BROWSER_CONFIRM_ACTIONS").ok() + .or(config.confirm_actions), + confirm_interactive: env_var_is_truthy("AGENT_BROWSER_CONFIRM_INTERACTIVE") + || config.confirm_interactive.unwrap_or(false), cli_executable_path: false, cli_extensions: false, cli_profile: false, @@ -455,6 +496,44 @@ pub fn parse_flags(args: &[String]) -> Flags { i += 1; } } + "--content-boundaries" => { + let (val, consumed) = parse_bool_arg(args, i); + flags.content_boundaries = val; + if consumed { i += 1; } + } + "--max-output" => { + if let Some(s) = args.get(i + 1) { + if let Ok(n) = s.parse::() { + flags.max_output = Some(n); + } + i += 1; + } + } + "--allowed-domains" => { + if let Some(s) = args.get(i + 1) { + flags.allowed_domains = Some( + s.split(',').map(|d| d.trim().to_lowercase()).filter(|d| !d.is_empty()).collect() + ); + i += 1; + } + } + "--action-policy" => { + if let Some(s) = args.get(i + 1) { + flags.action_policy = Some(s.clone()); + i += 1; + } + } + "--confirm-actions" => { + if let Some(s) = args.get(i + 1) { + flags.confirm_actions = Some(s.clone()); + i += 1; + } + } + "--confirm-interactive" => { + let (val, consumed) = parse_bool_arg(args, i); + flags.confirm_interactive = val; + if consumed { i += 1; } + } "--config" => { // Already handled by load_config(); skip the value i += 1; @@ -480,6 +559,8 @@ pub fn clean_args(args: &[String]) -> Vec { "--allow-file-access", "--auto-connect", "--annotate", + "--content-boundaries", + "--confirm-interactive", ]; // Global flags that always take a value (need to skip the next arg too) const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[ @@ -500,6 +581,10 @@ pub fn clean_args(args: &[String]) -> Vec { "--session-name", "--color-scheme", "--download-path", + "--max-output", + "--allowed-domains", + "--action-policy", + "--confirm-actions", "--config", ]; diff --git a/cli/src/main.rs b/cli/src/main.rs index 39d78de..4df940e 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -17,10 +17,108 @@ use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION}; use commands::{gen_id, parse_command, ParseError}; -use connection::{ensure_daemon, get_socket_dir, send_command}; +use connection::{ensure_daemon, get_socket_dir, send_command, DaemonOptions}; use flags::{clean_args, parse_flags}; use install::run_install; -use output::{print_command_help, print_help, print_response, print_version}; +use output::{print_command_help, print_help, print_response_with_opts, print_version, OutputOptions}; + +use std::path::PathBuf; +use std::process::Command as ProcessCommand; + +/// Run a local auth command (auth_save/list/show/delete) via node auth-cli.js. +/// These commands don't need a browser, so we handle them directly to avoid +/// sending passwords through the daemon's Unix socket channel. +fn run_auth_cli(cmd: &serde_json::Value, json_mode: bool) -> ! { + let exe_path = env::current_exe().unwrap_or_default(); + let exe_path = exe_path.canonicalize().unwrap_or(exe_path); + let exe_dir = exe_path.parent().unwrap_or(std::path::Path::new(".")); + + let mut script_paths = vec![ + exe_dir.join("auth-cli.js"), + exe_dir.join("../dist/auth-cli.js"), + PathBuf::from("dist/auth-cli.js"), + ]; + + if let Ok(home) = env::var("AGENT_BROWSER_HOME") { + let home_path = PathBuf::from(&home); + script_paths.insert(0, home_path.join("dist/auth-cli.js")); + script_paths.insert(1, home_path.join("auth-cli.js")); + } + + let script_path = match script_paths.iter().find(|p| p.exists()) { + Some(p) => p.clone(), + None => { + if json_mode { + println!(r#"{{"success":false,"error":"auth-cli.js not found"}}"#); + } else { + eprintln!( + "{} auth-cli.js not found. Set AGENT_BROWSER_HOME or run from project directory.", + color::error_indicator() + ); + } + exit(1); + } + }; + + let cmd_json = serde_json::to_string(cmd).unwrap_or_default(); + + match ProcessCommand::new("node") + .arg(&script_path) + .arg(&cmd_json) + .output() + { + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.is_empty() { + eprint!("{}", stderr); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let stdout = stdout.trim(); + + if stdout.is_empty() { + if json_mode { + println!(r#"{{"success":false,"error":"No response from auth-cli"}}"#); + } else { + eprintln!("{} No response from auth-cli", color::error_indicator()); + } + exit(1); + } + + if json_mode { + println!("{}", stdout); + } else { + // Parse the JSON response and use the standard output formatter + match serde_json::from_str::(stdout) { + Ok(resp) => { + let action = cmd.get("action").and_then(|v| v.as_str()); + let opts = OutputOptions { + json: false, + content_boundaries: false, + max_output: None, + }; + print_response_with_opts(&resp, action, &opts); + if !resp.success { + exit(1); + } + } + Err(_) => { + println!("{}", stdout); + } + } + } + exit(output.status.code().unwrap_or(0)); + } + Err(e) => { + if json_mode { + println!(r#"{{"success":false,"error":"Failed to run auth-cli: {}"}}"#, e); + } else { + eprintln!("{} Failed to run auth-cli: {}", color::error_indicator(), e); + } + exit(1); + } + } +} fn parse_proxy(proxy_str: &str) -> serde_json::Value { let Some(protocol_end) = proxy_str.find("://") else { @@ -171,7 +269,7 @@ fn main() { return; } - let cmd = match parse_command(&clean, &flags) { + let mut cmd = match parse_command(&clean, &flags) { Ok(c) => c, Err(e) => { if flags.json { @@ -194,6 +292,38 @@ fn main() { } }; + // Handle --password-stdin for auth save + if cmd.get("action").and_then(|v| v.as_str()) == Some("auth_save") { + if cmd.get("password").is_some() { + eprintln!( + "{} Passwords on the command line may be visible in process listings and shell history. Use --password-stdin instead.", + color::warning_indicator() + ); + } + if cmd.get("passwordStdin").and_then(|v| v.as_bool()).unwrap_or(false) { + let mut pass = String::new(); + if std::io::stdin().read_line(&mut pass).is_err() || pass.is_empty() { + eprintln!("{} Failed to read password from stdin", color::error_indicator()); + exit(1); + } + let pass = pass.trim_end_matches('\n').trim_end_matches('\r'); + if pass.is_empty() { + eprintln!("{} Password from stdin is empty", color::error_indicator()); + exit(1); + } + cmd["password"] = json!(pass); + cmd.as_object_mut().unwrap().remove("passwordStdin"); + } + } + + // Handle local auth commands without starting the daemon. + // These don't need a browser, so we avoid sending passwords through the socket. + if let Some(action) = cmd.get("action").and_then(|v| v.as_str()) { + if matches!(action, "auth_save" | "auth_list" | "auth_show" | "auth_delete") { + run_auth_cli(&cmd, flags.json); + } + } + // Validate session name before starting daemon if let Some(ref name) = flags.session_name { if !validation::is_valid_session_name(name) { @@ -210,24 +340,27 @@ fn main() { } } - let daemon_result = match ensure_daemon( - &flags.session, - flags.headed, - flags.executable_path.as_deref(), - &flags.extensions, - flags.args.as_deref(), - flags.user_agent.as_deref(), - flags.proxy.as_deref(), - flags.proxy_bypass.as_deref(), - flags.ignore_https_errors, - flags.allow_file_access, - flags.profile.as_deref(), - flags.state.as_deref(), - flags.provider.as_deref(), - flags.device.as_deref(), - flags.session_name.as_deref(), - flags.download_path.as_deref(), - ) { + let daemon_opts = DaemonOptions { + headed: flags.headed, + executable_path: flags.executable_path.as_deref(), + extensions: &flags.extensions, + args: flags.args.as_deref(), + user_agent: flags.user_agent.as_deref(), + proxy: flags.proxy.as_deref(), + proxy_bypass: flags.proxy_bypass.as_deref(), + ignore_https_errors: flags.ignore_https_errors, + allow_file_access: flags.allow_file_access, + profile: flags.profile.as_deref(), + state: flags.state.as_deref(), + provider: flags.provider.as_deref(), + 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(), + }; + let daemon_result = match ensure_daemon(&flags.session, &daemon_opts) { Ok(result) => result, Err(e) => { if flags.json { @@ -588,6 +721,10 @@ fn main() { launch_cmd["downloadPath"] = json!(dp); } + if let Some(ref domains) = flags.allowed_domains { + launch_cmd["allowedDomains"] = json!(domains); + } + match send_command(launch_cmd, &flags.session) { Ok(resp) if !resp.success => { // Launch command failed (e.g., invalid state file, profile error) @@ -619,12 +756,61 @@ fn main() { } } + let output_opts = OutputOptions { + json: flags.json, + content_boundaries: flags.content_boundaries, + max_output: flags.max_output, + }; + match send_command(cmd.clone(), &flags.session) { Ok(resp) => { let success = resp.success; + // Handle interactive confirmation + if flags.confirm_interactive { + if let Some(data) = &resp.data { + if data.get("confirmation_required").and_then(|v| v.as_bool()).unwrap_or(false) { + let desc = data.get("description").and_then(|v| v.as_str()).unwrap_or("unknown action"); + let category = data.get("category").and_then(|v| v.as_str()).unwrap_or(""); + let cid = data.get("confirmation_id").and_then(|v| v.as_str()).unwrap_or(""); + + eprintln!("[agent-browser] Action requires confirmation:"); + eprintln!(" {}: {}", category, desc); + eprint!(" Allow? [y/N]: "); + + let mut input = String::new(); + let approved = if std::io::IsTerminal::is_terminal(&std::io::stdin()) { + std::io::stdin().read_line(&mut input).is_ok() + && matches!(input.trim().to_lowercase().as_str(), "y" | "yes") + } else { + false + }; + + let confirm_cmd = if approved { + json!({ "id": gen_id(), "action": "confirm", "confirmationId": cid }) + } else { + json!({ "id": gen_id(), "action": "deny", "confirmationId": cid }) + }; + + match send_command(confirm_cmd, &flags.session) { + Ok(r) => { + if !approved { + eprintln!("{} Action denied", color::error_indicator()); + exit(1); + } + print_response_with_opts(&r, None, &output_opts); + } + Err(e) => { + eprintln!("{} {}", color::error_indicator(), e); + exit(1); + } + } + return; + } + } + } // Extract action for context-specific output handling let action = cmd.get("action").and_then(|v| v.as_str()); - print_response(&resp, flags.json, action); + print_response_with_opts(&resp, action, &output_opts); if !success { exit(1); } diff --git a/cli/src/output.rs b/cli/src/output.rs index 0d986d3..0006477 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -1,9 +1,83 @@ +use std::sync::OnceLock; + use crate::color; use crate::connection::Response; -pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { - if json_mode { - println!("{}", serde_json::to_string(resp).unwrap_or_default()); +static BOUNDARY_NONCE: OnceLock = OnceLock::new(); + +/// Per-process nonce for content boundary markers. Uses a CSPRNG (getrandom) so +/// that untrusted page content cannot predict or spoof the boundary delimiter. +/// Process ID or timestamps would be insufficient since pages can read those. +fn get_boundary_nonce() -> &'static str { + BOUNDARY_NONCE.get_or_init(|| { + let mut buf = [0u8; 16]; + getrandom::getrandom(&mut buf).expect("failed to generate random nonce"); + buf.iter().map(|b| format!("{:02x}", b)).collect() + }) +} + +#[derive(Default)] +pub struct OutputOptions { + pub json: bool, + pub content_boundaries: bool, + pub max_output: Option, +} + +fn truncate_if_needed(content: &str, max: Option) -> String { + let Some(limit) = max else { + return content.to_string(); + }; + // Fast path: byte length is a lower bound on char count, so if the + // byte length is within the limit the char count must be too. + if content.len() <= limit { + return content.to_string(); + } + // Find the byte offset of the limit-th character. + match content.char_indices().nth(limit).map(|(i, _)| i) { + Some(byte_offset) => { + let total_chars = content.chars().count(); + format!( + "{}\n[truncated: showing {} of {} chars. Use --max-output to adjust]", + &content[..byte_offset], limit, total_chars + ) + } + // Content has fewer than `limit` chars despite more bytes + None => content.to_string(), + } +} + +fn print_with_boundaries(content: &str, origin: Option<&str>, opts: &OutputOptions) { + let content = truncate_if_needed(content, opts.max_output); + if opts.content_boundaries { + let origin_str = origin.unwrap_or("unknown"); + let nonce = get_boundary_nonce(); + println!("--- AGENT_BROWSER_PAGE_CONTENT nonce={} origin={} ---", nonce, origin_str); + println!("{}", content); + println!("--- END_AGENT_BROWSER_PAGE_CONTENT nonce={} ---", nonce); + } else { + println!("{}", content); + } +} + +pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &OutputOptions) { + if opts.json { + if opts.content_boundaries { + let mut json_val = serde_json::to_value(resp).unwrap_or_default(); + if let Some(obj) = json_val.as_object_mut() { + let nonce = get_boundary_nonce(); + let origin = obj.get("data") + .and_then(|d| d.get("origin")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + obj.insert("_boundary".to_string(), serde_json::json!({ + "nonce": nonce, + "origin": origin, + })); + } + println!("{}", serde_json::to_string(&json_val).unwrap_or_default()); + } else { + println!("{}", serde_json::to_string(resp).unwrap_or_default()); + } return; } @@ -56,9 +130,10 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { _ => {} } } + let origin = data.get("origin").and_then(|v| v.as_str()); // Snapshot if let Some(snapshot) = data.get("snapshot").and_then(|v| v.as_str()) { - println!("{}", snapshot); + print_with_boundaries(snapshot, origin, opts); return; } // Title @@ -68,12 +143,12 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { } // Text if let Some(text) = data.get("text").and_then(|v| v.as_str()) { - println!("{}", text); + print_with_boundaries(text, origin, opts); return; } // HTML if let Some(html) = data.get("html").and_then(|v| v.as_str()) { - println!("{}", html); + print_with_boundaries(html, origin, opts); return; } // Value @@ -101,10 +176,8 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { } // Eval result if let Some(result) = data.get("result") { - println!( - "{}", - serde_json::to_string_pretty(result).unwrap_or_default() - ); + let formatted = serde_json::to_string_pretty(result).unwrap_or_default(); + print_with_boundaries(&formatted, origin, opts); return; } // iOS Devices @@ -191,10 +264,23 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { } // Console logs if let Some(logs) = data.get("messages").and_then(|v| v.as_array()) { - for log in logs { - let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log"); - let text = log.get("text").and_then(|v| v.as_str()).unwrap_or(""); - println!("{} {}", color::console_level_prefix(level), text); + if opts.content_boundaries { + let mut console_output = String::new(); + for log in logs { + let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log"); + let text = log.get("text").and_then(|v| v.as_str()).unwrap_or(""); + console_output.push_str(&format!("{} {}\n", color::console_level_prefix(level), text)); + } + if console_output.ends_with('\n') { + console_output.pop(); + } + print_with_boundaries(&console_output, origin, opts); + } else { + for log in logs { + let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log"); + let text = log.get("text").and_then(|v| v.as_str()).unwrap_or(""); + println!("{} {}", color::console_level_prefix(level), text); + } } return; } @@ -548,6 +634,87 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { println!("{}", note); return; } + // Auth list + if let Some(profiles) = data.get("profiles").and_then(|v| v.as_array()) { + if profiles.is_empty() { + println!("{}", color::dim("No auth profiles saved")); + } else { + println!("{}", color::bold("Auth profiles:")); + for p in profiles { + let name = p.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let url = p.get("url").and_then(|v| v.as_str()).unwrap_or(""); + let user = p.get("username").and_then(|v| v.as_str()).unwrap_or(""); + println!(" {} {} {}", color::green(name), color::dim(user), color::dim(url)); + } + } + return; + } + + // Auth show + if let Some(profile) = data.get("profile").and_then(|v| v.as_object()) { + let name = profile.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let url = profile.get("url").and_then(|v| v.as_str()).unwrap_or(""); + let user = profile.get("username").and_then(|v| v.as_str()).unwrap_or(""); + let created = profile.get("createdAt").and_then(|v| v.as_str()).unwrap_or(""); + let last_login = profile.get("lastLoginAt").and_then(|v| v.as_str()); + println!("Name: {}", name); + println!("URL: {}", url); + println!("Username: {}", user); + println!("Created: {}", created); + if let Some(ll) = last_login { + println!("Last login: {}", ll); + } + return; + } + + // Auth save/update/login/delete + if data.get("saved").and_then(|v| v.as_bool()).unwrap_or(false) { + let name = data.get("name").and_then(|v| v.as_str()).unwrap_or(""); + println!("{} Auth profile '{}' saved", color::success_indicator(), name); + return; + } + if data.get("updated").and_then(|v| v.as_bool()).unwrap_or(false) + && !data.get("saved").and_then(|v| v.as_bool()).unwrap_or(false) { + let name = data.get("name").and_then(|v| v.as_str()).unwrap_or(""); + println!("{} Auth profile '{}' updated", color::success_indicator(), name); + return; + } + if data.get("loggedIn").and_then(|v| v.as_bool()).unwrap_or(false) { + let name = data.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if let Some(title) = data.get("title").and_then(|v| v.as_str()) { + println!("{} Logged in as '{}' - {}", color::success_indicator(), name, title); + } else { + println!("{} Logged in as '{}'", color::success_indicator(), name); + } + return; + } + if data.get("deleted").and_then(|v| v.as_bool()).unwrap_or(false) { + if let Some(name) = data.get("name").and_then(|v| v.as_str()) { + println!("{} Auth profile '{}' deleted", color::success_indicator(), name); + return; + } + } + + // Confirmation required (for orchestrator use) + if data.get("confirmation_required").and_then(|v| v.as_bool()).unwrap_or(false) { + let category = data.get("category").and_then(|v| v.as_str()).unwrap_or(""); + let description = data.get("description").and_then(|v| v.as_str()).unwrap_or(""); + let cid = data.get("confirmation_id").and_then(|v| v.as_str()).unwrap_or(""); + println!("Confirmation required:"); + println!(" {}: {}", category, description); + println!(" Run: agent-browser confirm {}", cid); + println!(" Or: agent-browser deny {}", cid); + return; + } + if data.get("confirmed").and_then(|v| v.as_bool()).unwrap_or(false) { + println!("{} Action confirmed", color::success_indicator()); + return; + } + if data.get("denied").and_then(|v| v.as_bool()).unwrap_or(false) { + println!("{} Action denied", color::success_indicator()); + return; + } + // Default success println!("{} Done", color::success_indicator()); } @@ -1546,6 +1713,64 @@ Examples: "## } + // === Auth === + "auth" => { + r##" +agent-browser auth - Manage authentication profiles + +Usage: agent-browser auth [args] + +Subcommands: + save Save credentials for a login profile + login Login using saved credentials + list List saved profiles (names and URLs only) + show Show profile metadata (no passwords) + delete Delete a saved profile + +Save Options: + --url Login page URL (required) + --username Username (required) + --password Password (required unless --password-stdin) + --password-stdin Read password from stdin (recommended) + --username-selector Custom CSS selector for username field + --password-selector Custom CSS selector for password field + --submit-selector Custom CSS selector for submit button + +Global Options: + --json Output as JSON + --session Use specific session + +Examples: + echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin + agent-browser auth save github --url https://github.com/login --username user --password pass + agent-browser auth login github + agent-browser auth list + agent-browser auth show github + agent-browser auth delete github +"## + } + + // === Confirm/Deny === + "confirm" | "deny" => { + r##" +agent-browser confirm/deny - Approve or deny pending actions + +Usage: + agent-browser confirm + agent-browser deny + +When --confirm-actions is set, certain action categories return a +confirmation_required response with a confirmation ID. Use confirm/deny +to approve or reject the action. + +Pending confirmations auto-deny after 60 seconds. + +Examples: + agent-browser confirm c_8f3a1234 + agent-browser deny c_8f3a1234 +"## + } + // === Dialog === "dialog" => { r##" @@ -2071,6 +2296,17 @@ Debug: errors [--clear] View page errors highlight Highlight element +Auth Vault: + auth save [opts] Save auth profile (--url, --username, --password/--password-stdin) + auth login Login using saved credentials + auth list List saved auth profiles + auth show Show auth profile metadata + auth delete Delete auth profile + +Confirmation: + confirm Approve a pending action + deny Deny a pending action + Sessions: session Show current session name session list List active sessions @@ -2112,6 +2348,12 @@ Options: --color-scheme Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME) --download-path Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH) --session-name Auto-save/restore session state (cookies, localStorage) + --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) + --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) --config Use a custom config file (or AGENT_BROWSER_CONFIG env) --debug Debug output --version, -V Show version @@ -2161,6 +2403,12 @@ Environment: AGENT_BROWSER_STREAM_PORT Enable WebSocket streaming on port (e.g., 9223) 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 + AGENT_BROWSER_MAX_OUTPUT Max characters for page output + AGENT_BROWSER_ALLOWED_DOMAINS Comma-separated allowed 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 Install (recommended, fastest - native Rust CLI): npm install -g agent-browser diff --git a/docs/src/app/security/page.mdx b/docs/src/app/security/page.mdx new file mode 100644 index 0000000..b1813ca --- /dev/null +++ b/docs/src/app/security/page.mdx @@ -0,0 +1,243 @@ +import { pageMetadata } from "@/lib/page-metadata" +export const metadata = pageMetadata("security") + +# Security + +agent-browser includes security features to protect against credential exposure, prompt injection via untrusted page content, and unauthorized browser actions. + +All security features are opt-in. By default, agent-browser imposes no restrictions on navigation, actions, or output. Enable these features as needed for your deployment -- existing workflows are unaffected until you explicitly activate a feature. + +## Threat Model + +These features are designed to mitigate the following threats when an LLM-based agent drives a browser: + +- **Credential exposure** -- Passwords stored in the auth vault are never included in LLM context. The CLI handles vault operations locally; credentials do not pass through the daemon's IPC channel. +- **Prompt injection via page content** -- Malicious pages can embed text that looks like tool output or system instructions. Content boundary markers (`--content-boundaries`) let the orchestrator distinguish trusted tool output from untrusted page content. +- **Unauthorized navigation / data exfiltration** -- A compromised or manipulated agent could navigate to attacker-controlled domains to exfiltrate data. The domain allowlist (`--allowed-domains`) blocks navigations, sub-resource requests, WebSocket connections, EventSource streams, and `sendBeacon` calls to non-allowed domains. +- **Unauthorized destructive actions** -- Action policy (`--action-policy`) and confirmation gating (`--confirm-actions`) prevent the agent from performing dangerous operations (eval, downloads, uploads) without explicit approval. +- **Context flooding** -- Large page outputs can overwhelm an LLM's context window. Output truncation (`--max-output`) caps the size of page-sourced content. + +### Known limitations + +- **WebSocket/EventSource blocking is best-effort.** It works by overriding browser constructors via an init script. If the `eval` action category is allowed, page scripts could theoretically restore the original constructors. Deny `eval` via `--action-policy` for maximum protection. +- **Domain filter timing on remote connections.** When connecting to a pre-existing browser via CDP or a cloud provider, pages may have already loaded content before the domain filter is installed. agent-browser navigates disallowed pages to `about:blank` after the filter is active, but resources loaded before that point are not retroactively blocked. +- **Content boundaries are defense-in-depth.** They rely on the LLM and orchestrator respecting the structural markers. A sufficiently capable adversarial page could attempt to mimic the boundary format, though the per-process CSPRNG nonce makes this impractical to predict. +- **Confirmation timeout.** Pending confirmations auto-deny after 60 seconds. Orchestrators must respond within that window. +- **Non-TTY auto-deny.** When `--confirm-interactive` is set but stdin is not a terminal (e.g., piped input), actions are automatically denied to prevent accidental approval in non-interactive contexts. + +## Authentication Vault + +Store credentials locally and reference them by name. The LLM never sees passwords. + +```bash +# Save credentials (encrypted if AGENT_BROWSER_ENCRYPTION_KEY is set) +# Recommended: pipe password via stdin to avoid shell history / process listing exposure +echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin + +# Or pass directly (a warning will be shown) +agent-browser auth save github --url https://github.com/login --username user --password pass + +# Login using saved credentials +agent-browser auth login github + +# List saved profiles (names and URLs only, no secrets) +agent-browser auth list + +# Show profile metadata +agent-browser auth show github + +# Delete a profile +agent-browser auth delete github +``` + +Custom selectors can be specified if auto-detection fails: + +```bash +agent-browser auth save myapp \ + --url https://app.example.com/login \ + --username user --password pass \ + --username-selector "#email" \ + --password-selector "#password" \ + --submit-selector "button.login" +``` + +Profiles are stored in `~/.agent-browser/auth/` and always encrypted with AES-256-GCM. If `AGENT_BROWSER_ENCRYPTION_KEY` is not set, a key is auto-generated at `~/.agent-browser/.encryption-key` on first use. Back up this file or set the environment variable explicitly for portability. + +File permissions are enforced on both Unix (`chmod 600`/`700`) and Windows (`icacls` restricted to the current user) to prevent other users from reading encryption keys or auth profiles. + +## Content Boundary Markers + +When `--content-boundaries` is enabled, all page-sourced output is wrapped in structural markers so LLMs can distinguish tool output from untrusted page content: + +``` +--- AGENT_BROWSER_PAGE_CONTENT nonce=a1b2c3d4 origin=https://example.com --- +[snapshot / text / html / eval output here] +--- END_AGENT_BROWSER_PAGE_CONTENT nonce=a1b2c3d4 --- +``` + +The nonce is a random value generated per CLI process invocation, making it unpredictable to page content that might attempt to spoof the boundary. + +Enable via flag or environment variable: + +```bash +agent-browser --content-boundaries snapshot +# or +export AGENT_BROWSER_CONTENT_BOUNDARIES=1 +``` + +Affected output types: `snapshot`, `get text`, `get html`, `eval`, `console`. + +In `--json` mode, boundary metadata is injected into the JSON response as a `_boundary` object containing `nonce` and `origin` fields, allowing orchestrators to verify provenance programmatically: + +```json +{ + "success": true, + "data": { "snapshot": "...", "origin": "https://example.com" }, + "_boundary": { "nonce": "a1b2c3d4e5f6...", "origin": "https://example.com" } +} +``` + +## Domain Allowlist + +Restrict which domains the browser can interact with, preventing redirect-based attacks and data exfiltration: + +```bash +agent-browser --allowed-domains "example.com,*.example.com,github.com" open https://example.com +# or +export AGENT_BROWSER_ALLOWED_DOMAINS="example.com,*.example.com" +``` + +Supports exact match (`github.com`) and wildcard prefix (`*.example.com`, which also matches the bare domain `example.com`). Both page navigations and sub-resource requests (scripts, images, fetch, XHR, etc.) to non-allowed domains are blocked, preventing data exfiltration. WebSocket and EventSource connections are also blocked via constructor-level patching. Non-http(s) sub-resources (data URIs, blobs) are still allowed. When a request is blocked, the command returns an error. + +> **Note:** The WebSocket/EventSource blocking is best-effort -- it works by overriding the browser constructors via an init script. If the `eval` action category is allowed, page scripts could theoretically restore the original constructors. For maximum protection, deny the `eval` category via `--action-policy` when using `--allowed-domains`. + +Config file: + +```json +{ + "allowedDomains": ["example.com", "*.example.com", "github.com"] +} +``` + +> **CDN and third-party resources:** The domain filter blocks all sub-resource requests (scripts, stylesheets, images, fonts, fetch/XHR) to non-allowed domains. Most websites load assets from CDN domains. Include these in your allowlist or pages will break. For example: +> +> ```bash +> --allowed-domains "myapp.com,*.myapp.com,cdn.jsdelivr.net,fonts.googleapis.com,fonts.gstatic.com" +> ``` + +## Action Policy + +Gate actions using a static policy file. The policy is enforced by the daemon -- denied actions fail immediately. + +```bash +agent-browser --action-policy ./policy.json open https://example.com +# or +export AGENT_BROWSER_ACTION_POLICY=./policy.json +``` + +Example policy (permissive with specific denials): + +```json +{ + "default": "allow", + "deny": ["eval", "download", "upload"] +} +``` + +Example policy (restrictive): + +```json +{ + "default": "deny", + "allow": ["navigate", "snapshot", "click", "scroll", "wait", "get"] +} +``` + + + + + + + + + + + + + + + + + + + + +
CategoryActions
navigateopen, back, forward, reload, tab new
clickclick, dblclick, tap
fillfill, type, keyboard type/inserttext, select, check, uncheck
evaleval, evalhandle, addscript, addinitscript, addstyle, expose, setcontent
downloaddownload, waitfordownload
uploadupload
snapshotsnapshot, screenshot, pdf, diff
scrollscroll, scrollintoview
waitwait, waitforurl, waitforloadstate, waitforfunction
getget text/html/url/title, count, isvisible, getbyrole, getbytext, getbylabel, etc.
interacthover, focus, drag, press, keydown, keyup, mousemove, dispatch
networknetwork route/unroute, requests
statestate save/load, cookies set, storage set
+ +Auth vault operations (`auth save`, `auth login`, `auth list`, `auth show`, `auth delete`) and other internal/meta operations bypass action policy enforcement since they are trusted local operations. Domain allowlist restrictions still apply to `auth login` navigations. + +## Action Confirmation + +For actions that require explicit approval, use `--confirm-actions` to specify categories that require confirmation: + +```bash +# Orchestrator mode: returns confirmation_required response +agent-browser --confirm-actions eval,download eval "document.title" + +# Then approve or deny: +agent-browser confirm c_8f3a1234 +agent-browser deny c_8f3a1234 +``` + +For interactive (human-in-the-loop) confirmation: + +```bash +agent-browser --confirm-actions eval,download --confirm-interactive eval "document.title" +# Prompts: Allow? [y/N] +``` + +Pending confirmations auto-deny after 60 seconds. + +> **Non-TTY behavior:** When `--confirm-interactive` is set but stdin is not a TTY (e.g., piped input or running inside an automated pipeline), actions are automatically denied. This prevents accidental approval in non-interactive contexts. + +## Output Length Limits + +Prevent context flooding by truncating large page outputs: + +```bash +agent-browser --max-output 50000 get text body +# or +export AGENT_BROWSER_MAX_OUTPUT=50000 +``` + +Affected output types: `snapshot`, `get text`, `get html`, `eval`, `console`. + +## Environment Variables + + + + + + + + + + + + + + +
VariableDescription
AGENT_BROWSER_CONTENT_BOUNDARIESWrap page output in boundary markers
AGENT_BROWSER_MAX_OUTPUTMax characters for page output
AGENT_BROWSER_ALLOWED_DOMAINSComma-separated allowed domain patterns
AGENT_BROWSER_ACTION_POLICYPath to action policy JSON file
AGENT_BROWSER_CONFIRM_ACTIONSComma-separated action categories requiring confirmation
AGENT_BROWSER_CONFIRM_INTERACTIVEEnable interactive confirmation prompts
AGENT_BROWSER_ENCRYPTION_KEY64-char hex key for AES-256-GCM encryption (auth vault + sessions)
+ +## Recommended Configuration + +For production AI agent deployments: + +```json +{ + "contentBoundaries": true, + "maxOutput": 50000, + "allowedDomains": ["your-app.com", "*.your-app.com"], + "actionPolicy": "./policy.json" +} +``` diff --git a/docs/src/lib/docs-navigation.ts b/docs/src/lib/docs-navigation.ts index 4772231..4cf4b14 100644 --- a/docs/src/lib/docs-navigation.ts +++ b/docs/src/lib/docs-navigation.ts @@ -35,6 +35,7 @@ export const navigation: NavSection[] = [ { name: "Streaming", href: "/streaming" }, { name: "Profiler", href: "/profiler" }, { name: "iOS Simulator", href: "/ios" }, + { name: "Security", href: "/security" }, ], }, { diff --git a/docs/src/lib/page-titles.ts b/docs/src/lib/page-titles.ts index a43ff5e..379315b 100644 --- a/docs/src/lib/page-titles.ts +++ b/docs/src/lib/page-titles.ts @@ -12,6 +12,7 @@ export const PAGE_TITLES: Record = { streaming: "Streaming", profiler: "Profiler", ios: "iOS Simulator", + security: "Security", changelog: "Changelog", }; diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index e691527..9f6e9be 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -115,6 +115,22 @@ agent-browser click @e5 agent-browser wait --load networkidle ``` +### Authentication with Auth Vault (Recommended) + +```bash +# Save credentials once (encrypted with AGENT_BROWSER_ENCRYPTION_KEY) +# Recommended: pipe password via stdin to avoid shell history exposure +echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin + +# Login using saved profile (LLM never sees password) +agent-browser auth login github + +# List/show/delete profiles +agent-browser auth list +agent-browser auth show github +agent-browser auth delete github +``` + ### Authentication with State Persistence ```bash @@ -248,6 +264,56 @@ agent-browser -p ios close **Real devices:** Works with physical iOS devices if pre-configured. Use `--device ""` where UDID is from `xcrun xctrace list devices`. +## Security + +All security features are opt-in. By default, agent-browser imposes no restrictions on navigation, actions, or output. + +### Content Boundaries (Recommended for AI Agents) + +Enable `--content-boundaries` to wrap page-sourced output in markers that help LLMs distinguish tool output from untrusted page content: + +```bash +export AGENT_BROWSER_CONTENT_BOUNDARIES=1 +agent-browser snapshot +# Output: +# --- AGENT_BROWSER_PAGE_CONTENT nonce= origin=https://example.com --- +# [accessibility tree] +# --- END_AGENT_BROWSER_PAGE_CONTENT nonce= --- +``` + +### 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: + +```bash +export AGENT_BROWSER_ALLOWED_DOMAINS="example.com,*.example.com" +agent-browser open https://example.com # OK +agent-browser open https://malicious.com # Blocked +``` + +### Action Policy + +Use a policy file to gate destructive actions: + +```bash +export AGENT_BROWSER_ACTION_POLICY=./policy.json +``` + +Example `policy.json`: +```json +{"default": "deny", "allow": ["navigate", "snapshot", "click", "scroll", "wait", "get"]} +``` + +Auth vault operations (`auth login`, etc.) bypass action policy but domain allowlist still applies. + +### Output Limits + +Prevent context flooding from large pages: + +```bash +export AGENT_BROWSER_MAX_OUTPUT=50000 +``` + ## Diffing (Verifying Changes) Use `diff snapshot` after performing an action to verify it had the intended effect. This compares the current accessibility tree against the last snapshot taken in the session. diff --git a/skills/agent-browser/templates/authenticated-session.sh b/skills/agent-browser/templates/authenticated-session.sh index f9984c6..b66c928 100755 --- a/skills/agent-browser/templates/authenticated-session.sh +++ b/skills/agent-browser/templates/authenticated-session.sh @@ -3,6 +3,11 @@ # Purpose: Login once, save state, reuse for subsequent runs # Usage: ./authenticated-session.sh [state-file] # +# RECOMMENDED: Use the auth vault instead of this template: +# echo "" | agent-browser auth save myapp --url --username --password-stdin +# agent-browser auth login myapp +# The auth vault stores credentials securely and the LLM never sees passwords. +# # Environment variables: # APP_USERNAME - Login username/email # APP_PASSWORD - Login password diff --git a/src/action-policy.test.ts b/src/action-policy.test.ts new file mode 100644 index 0000000..edc68fe --- /dev/null +++ b/src/action-policy.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + getActionCategory, + checkPolicy, + loadPolicyFile, + describeAction, + KNOWN_CATEGORIES, + type ActionPolicy, +} from './action-policy.js'; + +describe('action-policy', () => { + describe('getActionCategory', () => { + it('should return correct category for known actions', () => { + expect(getActionCategory('navigate')).toBe('navigate'); + expect(getActionCategory('click')).toBe('click'); + expect(getActionCategory('fill')).toBe('fill'); + expect(getActionCategory('evaluate')).toBe('eval'); + expect(getActionCategory('download')).toBe('download'); + expect(getActionCategory('upload')).toBe('upload'); + expect(getActionCategory('snapshot')).toBe('snapshot'); + expect(getActionCategory('scroll')).toBe('scroll'); + expect(getActionCategory('wait')).toBe('wait'); + expect(getActionCategory('gettext')).toBe('get'); + expect(getActionCategory('route')).toBe('network'); + expect(getActionCategory('state_save')).toBe('state'); + expect(getActionCategory('hover')).toBe('interact'); + }); + + it('should return _internal for internal actions', () => { + expect(getActionCategory('launch')).toBe('_internal'); + expect(getActionCategory('close')).toBe('_internal'); + expect(getActionCategory('session')).toBe('_internal'); + expect(getActionCategory('auth_save')).toBe('_internal'); + expect(getActionCategory('confirm')).toBe('_internal'); + }); + + it('should return eval for security-sensitive actions', () => { + expect(getActionCategory('setcontent')).toBe('eval'); + expect(getActionCategory('expose')).toBe('eval'); + expect(getActionCategory('addstyle')).toBe('eval'); + }); + + it('should return unknown for unrecognized actions', () => { + expect(getActionCategory('nonexistent')).toBe('unknown'); + expect(getActionCategory('')).toBe('unknown'); + }); + + it('should return get for semantic locator actions', () => { + expect(getActionCategory('getbyrole')).toBe('get'); + expect(getActionCategory('getbytext')).toBe('get'); + expect(getActionCategory('getbylabel')).toBe('get'); + }); + }); + + describe('checkPolicy', () => { + it('should always allow internal actions regardless of policy', () => { + const denyAll: ActionPolicy = { default: 'deny' }; + expect(checkPolicy('launch', denyAll, new Set())).toBe('allow'); + expect(checkPolicy('close', denyAll, new Set())).toBe('allow'); + expect(checkPolicy('session', denyAll, new Set())).toBe('allow'); + }); + + it('should allow all when no policy and no confirm categories', () => { + expect(checkPolicy('navigate', null, new Set())).toBe('allow'); + expect(checkPolicy('click', null, new Set())).toBe('allow'); + expect(checkPolicy('evaluate', null, new Set())).toBe('allow'); + }); + + it('should deny actions in explicit deny list', () => { + const policy: ActionPolicy = { default: 'allow', deny: ['eval', 'download'] }; + expect(checkPolicy('evaluate', policy, new Set())).toBe('deny'); + expect(checkPolicy('download', policy, new Set())).toBe('deny'); + expect(checkPolicy('click', policy, new Set())).toBe('allow'); + }); + + it('should allow actions in explicit allow list with deny default', () => { + const policy: ActionPolicy = { default: 'deny', allow: ['navigate', 'snapshot'] }; + expect(checkPolicy('navigate', policy, new Set())).toBe('allow'); + expect(checkPolicy('snapshot', policy, new Set())).toBe('allow'); + expect(checkPolicy('click', policy, new Set())).toBe('deny'); + }); + + it('should return confirm for actions in confirm categories', () => { + expect(checkPolicy('evaluate', null, new Set(['eval']))).toBe('confirm'); + expect(checkPolicy('download', null, new Set(['download']))).toBe('confirm'); + }); + + it('should deny over confirm when action is in deny list', () => { + const policy: ActionPolicy = { default: 'allow', deny: ['eval'] }; + expect(checkPolicy('evaluate', policy, new Set(['eval']))).toBe('deny'); + }); + + it('should use default policy for unknown categories', () => { + const denyPolicy: ActionPolicy = { default: 'deny' }; + const allowPolicy: ActionPolicy = { default: 'allow' }; + expect(checkPolicy('nonexistent', denyPolicy, new Set())).toBe('deny'); + expect(checkPolicy('nonexistent', allowPolicy, new Set())).toBe('allow'); + }); + }); + + describe('loadPolicyFile', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'action-policy-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should load a valid allow-default policy', () => { + const policyPath = path.join(tempDir, 'policy.json'); + fs.writeFileSync(policyPath, JSON.stringify({ default: 'allow', deny: ['eval'] })); + const policy = loadPolicyFile(policyPath); + expect(policy.default).toBe('allow'); + expect(policy.deny).toEqual(['eval']); + }); + + it('should load a valid deny-default policy', () => { + const policyPath = path.join(tempDir, 'policy.json'); + fs.writeFileSync( + policyPath, + JSON.stringify({ default: 'deny', allow: ['navigate', 'snapshot'] }) + ); + const policy = loadPolicyFile(policyPath); + expect(policy.default).toBe('deny'); + expect(policy.allow).toEqual(['navigate', 'snapshot']); + }); + + it('should throw on invalid default value', () => { + const policyPath = path.join(tempDir, 'policy.json'); + fs.writeFileSync(policyPath, JSON.stringify({ default: 'maybe' })); + expect(() => loadPolicyFile(policyPath)).toThrow('must be "allow" or "deny"'); + }); + + it('should throw on missing file', () => { + expect(() => loadPolicyFile(path.join(tempDir, 'missing.json'))).toThrow(); + }); + + it('should throw on invalid JSON', () => { + const policyPath = path.join(tempDir, 'policy.json'); + fs.writeFileSync(policyPath, 'not json'); + expect(() => loadPolicyFile(policyPath)).toThrow(); + }); + + it('should warn on unrecognized category names', () => { + const policyPath = path.join(tempDir, 'policy.json'); + fs.writeFileSync( + policyPath, + JSON.stringify({ default: 'allow', deny: ['eval', 'typo_category'] }) + ); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const policy = loadPolicyFile(policyPath); + expect(policy.default).toBe('allow'); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('unrecognized action category "typo_category"') + ); + warnSpy.mockRestore(); + }); + + it('should not warn on valid category names', () => { + const policyPath = path.join(tempDir, 'policy.json'); + fs.writeFileSync( + policyPath, + JSON.stringify({ default: 'deny', allow: ['navigate', 'snapshot', 'get'] }) + ); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + loadPolicyFile(policyPath); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + }); + + describe('describeAction', () => { + it('should describe navigate actions', () => { + expect(describeAction('navigate', { url: 'https://example.com' })).toBe( + 'Navigate to https://example.com' + ); + }); + + it('should describe eval actions with truncation', () => { + const longScript = 'a'.repeat(200); + const desc = describeAction('evaluate', { script: longScript }); + expect(desc).toContain('Evaluate JavaScript:'); + expect(desc.length).toBeLessThan(200); + }); + + it('should describe click actions', () => { + expect(describeAction('click', { selector: '#btn' })).toBe('Click #btn'); + }); + + it('should describe dblclick actions', () => { + expect(describeAction('dblclick', { selector: '#btn' })).toBe('Double-click #btn'); + }); + + it('should describe tap actions', () => { + expect(describeAction('tap', { selector: '#btn' })).toBe('Tap #btn'); + }); + + it('should describe fill actions', () => { + expect(describeAction('fill', { selector: '#input' })).toBe('Fill #input'); + }); + + it('should use fallback for unknown actions', () => { + const desc = describeAction('scroll', {}); + expect(desc).toContain('scroll'); + }); + }); +}); diff --git a/src/action-policy.ts b/src/action-policy.ts new file mode 100644 index 0000000..b9847d2 --- /dev/null +++ b/src/action-policy.ts @@ -0,0 +1,297 @@ +import { readFileSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; + +export interface ActionPolicy { + default: 'allow' | 'deny'; + allow?: string[]; + deny?: string[]; +} + +export type PolicyDecision = 'allow' | 'deny' | 'confirm'; + +const ACTION_CATEGORIES: Record = { + navigate: 'navigate', + back: 'navigate', + forward: 'navigate', + reload: 'navigate', + tab_new: 'navigate', + + click: 'click', + dblclick: 'click', + tap: 'click', + + fill: 'fill', + type: 'fill', + // The `keyboard` action is a compound command that dispatches to sub-actions + // (type, inserttext, press, down, up). Its primary use is text input, so it + // maps to 'fill'. The interact-like sub-actions (press, down, up) are less + // common and don't have separate top-level action names in the protocol. + keyboard: 'fill', + inserttext: 'fill', + select: 'fill', + multiselect: 'fill', + check: 'fill', + uncheck: 'fill', + clear: 'fill', + selectall: 'fill', + setvalue: 'fill', + + download: 'download', + waitfordownload: 'download', + + upload: 'upload', + + evaluate: 'eval', + evalhandle: 'eval', + addscript: 'eval', + addinitscript: 'eval', + + snapshot: 'snapshot', + screenshot: 'snapshot', + pdf: 'snapshot', + diff_snapshot: 'snapshot', + diff_screenshot: 'snapshot', + diff_url: 'snapshot', + + scroll: 'scroll', + scrollintoview: 'scroll', + + wait: 'wait', + waitforurl: 'wait', + waitforloadstate: 'wait', + waitforfunction: 'wait', + + gettext: 'get', + content: 'get', + innerhtml: 'get', + innertext: 'get', + inputvalue: 'get', + url: 'get', + title: 'get', + getattribute: 'get', + count: 'get', + boundingbox: 'get', + styles: 'get', + isvisible: 'get', + isenabled: 'get', + ischecked: 'get', + responsebody: 'get', + + route: 'network', + unroute: 'network', + requests: 'network', + + state_save: 'state', + state_load: 'state', + cookies_set: 'state', + storage_set: 'state', + credentials: 'state', + + hover: 'interact', + focus: 'interact', + drag: 'interact', + press: 'interact', + keydown: 'interact', + keyup: 'interact', + mousemove: 'interact', + mousedown: 'interact', + mouseup: 'interact', + wheel: 'interact', + dispatch: 'interact', + + // These are always allowed (internal/meta operations) + launch: '_internal', + close: '_internal', + tab_list: '_internal', + tab_switch: '_internal', + tab_close: '_internal', + window_new: '_internal', + frame: '_internal', + mainframe: '_internal', + dialog: '_internal', + session: '_internal', + console: '_internal', + errors: '_internal', + cookies_get: '_internal', + cookies_clear: '_internal', + storage_get: '_internal', + storage_clear: '_internal', + state_list: '_internal', + state_show: '_internal', + state_clear: '_internal', + state_clean: '_internal', + state_rename: '_internal', + highlight: '_internal', + bringtofront: '_internal', + trace_start: '_internal', + trace_stop: '_internal', + har_start: '_internal', + har_stop: '_internal', + video_start: '_internal', + video_stop: '_internal', + recording_start: '_internal', + recording_stop: '_internal', + recording_restart: '_internal', + profiler_start: '_internal', + profiler_stop: '_internal', + clipboard: '_internal', + viewport: '_internal', + useragent: '_internal', + device: '_internal', + geolocation: '_internal', + permissions: '_internal', + emulatemedia: '_internal', + offline: '_internal', + headers: '_internal', + addstyle: 'eval', + expose: 'eval', + timezone: '_internal', + locale: '_internal', + pause: '_internal', + setcontent: 'eval', + screencast_start: '_internal', + screencast_stop: '_internal', + input_mouse: '_internal', + input_keyboard: '_internal', + input_touch: '_internal', + + auth_save: '_internal', + auth_login: '_internal', + auth_list: '_internal', + auth_delete: '_internal', + auth_show: '_internal', + confirm: '_internal', + deny: '_internal', + + // Find/semantic locator actions (read-only element resolution) + getbyrole: 'get', + getbytext: 'get', + getbylabel: 'get', + getbyplaceholder: 'get', + getbyalttext: 'get', + getbytitle: 'get', + getbytestid: 'get', + nth: 'get', +}; + +// User-facing categories used in policy files. '_internal' is excluded because +// internal actions always bypass policy. 'unknown' is intentionally not a value +// in ACTION_CATEGORIES -- it is only the fallback return of getActionCategory() +// for unrecognized actions. If a user puts "unknown" in a policy file, +// loadPolicyFile will warn about it as unrecognized, which is correct. +export const KNOWN_CATEGORIES = new Set( + Object.values(ACTION_CATEGORIES).filter((c) => c !== '_internal') +); + +export function getActionCategory(action: string): string { + return ACTION_CATEGORIES[action] ?? 'unknown'; +} + +export function loadPolicyFile(policyPath: string): ActionPolicy { + const resolved = resolve(policyPath); + const content = readFileSync(resolved, 'utf-8'); + const policy = JSON.parse(content) as ActionPolicy; + + if (policy.default !== 'allow' && policy.default !== 'deny') { + throw new Error( + `Invalid action policy: "default" must be "allow" or "deny", got "${policy.default}"` + ); + } + + for (const list of [policy.allow, policy.deny]) { + if (!list) continue; + for (const category of list) { + if (!KNOWN_CATEGORIES.has(category)) { + console.warn( + `[agent-browser] Warning: unrecognized action category "${category}" in policy file. ` + + `Known categories: ${[...KNOWN_CATEGORIES].sort().join(', ')}` + ); + } + } + } + + return policy; +} + +let cachedPolicyPath: string | null = null; +let cachedPolicyMtimeMs = 0; +let cachedPolicy: ActionPolicy | null = null; +const RELOAD_CHECK_INTERVAL_MS = 5_000; +let lastCheckMs = 0; + +export function initPolicyReloader(policyPath: string, policy: ActionPolicy): void { + cachedPolicyPath = resolve(policyPath); + cachedPolicyMtimeMs = statSync(cachedPolicyPath).mtimeMs; + cachedPolicy = policy; +} + +export function reloadPolicyIfChanged(): ActionPolicy | null { + if (!cachedPolicyPath) return cachedPolicy; + + const now = Date.now(); + if (now - lastCheckMs < RELOAD_CHECK_INTERVAL_MS) return cachedPolicy; + lastCheckMs = now; + + try { + const currentMtime = statSync(cachedPolicyPath).mtimeMs; + if (currentMtime !== cachedPolicyMtimeMs) { + cachedPolicy = loadPolicyFile(cachedPolicyPath); + cachedPolicyMtimeMs = currentMtime; + } + } catch { + // File may have been removed; keep using cached policy + } + + return cachedPolicy; +} + +export function checkPolicy( + action: string, + policy: ActionPolicy | null, + confirmCategories: Set +): PolicyDecision { + const category = getActionCategory(action); + + // Internal actions are always allowed + if (category === '_internal') return 'allow'; + + // Explicit deny takes precedence over confirmation + if (policy?.deny?.includes(category)) return 'deny'; + + // Check if this category requires confirmation + if (confirmCategories.has(category)) return 'confirm'; + + if (!policy) return 'allow'; + + // Explicit allow list + if (policy.allow?.includes(category)) return 'allow'; + + return policy.default; +} + +export function describeAction(action: string, command: Record): string { + const category = getActionCategory(action); + switch (action) { + case 'navigate': + return `Navigate to ${command.url}`; + case 'evaluate': + case 'evalhandle': + return `Evaluate JavaScript: ${String(command.script ?? '').slice(0, 80)}`; + case 'fill': + return `Fill ${command.selector}`; + case 'type': + return `Type into ${command.selector}`; + case 'click': + return `Click ${command.selector}`; + case 'dblclick': + return `Double-click ${command.selector}`; + case 'tap': + return `Tap ${command.selector}`; + case 'download': + return `Download via ${command.selector} to ${command.path}`; + case 'upload': + return `Upload files to ${command.selector}`; + default: + return `${category}: ${action}`; + } +} diff --git a/src/actions.ts b/src/actions.ts index fc012e6..5b5b06f 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -4,6 +4,17 @@ import type { Page, Frame } from 'playwright-core'; import { mkdirSync } from 'node:fs'; import type { BrowserManager, ScreencastFrame } from './browser.js'; import { getAppDir } from './daemon.js'; +import { + type ActionPolicy, + checkPolicy, + describeAction, + getActionCategory, + loadPolicyFile, + initPolicyReloader, + reloadPolicyIfChanged, +} from './action-policy.js'; +import { requestConfirmation, getAndRemovePending } from './confirmation.js'; +import { getAuthProfile, updateLastLogin } from './auth-vault.js'; import { getSessionsDir, readStateFile, @@ -126,6 +137,9 @@ import type { DiffSnapshotCommand, DiffScreenshotCommand, DiffUrlCommand, + AuthLoginCommand, + ConfirmCommand, + DenyCommand, Annotation, NavigateData, ScreenshotData, @@ -146,7 +160,7 @@ import type { InputEventData, StylesData, } from './types.js'; -import { successResponse, errorResponse } from './protocol.js'; +import { successResponse, errorResponse, parseCommand } from './protocol.js'; import { diffSnapshots, diffScreenshots } from './diff.js'; import { getEnhancedSnapshot } from './snapshot.js'; @@ -228,290 +242,365 @@ export function toAIFriendlyError(error: unknown, selector: string): Error { return error instanceof Error ? error : new Error(message); } +let actionPolicy: ActionPolicy | null = null; +let confirmCategories = new Set(); + +export function initActionPolicy(): void { + const policyPath = process.env.AGENT_BROWSER_ACTION_POLICY; + if (policyPath) { + try { + actionPolicy = loadPolicyFile(policyPath); + initPolicyReloader(policyPath, actionPolicy); + } catch (err) { + console.error( + `[ERROR] Failed to load action policy from ${policyPath}: ${err instanceof Error ? err.message : err}` + ); + process.exit(1); + } + } + + const confirmActionsEnv = process.env.AGENT_BROWSER_CONFIRM_ACTIONS; + if (confirmActionsEnv) { + confirmCategories = new Set( + confirmActionsEnv + .split(',') + .map((c) => c.trim().toLowerCase()) + .filter((c) => c.length > 0) + ); + } +} + /** * Execute a command and return a response */ export async function executeCommand(command: Command, browser: BrowserManager): Promise { try { - switch (command.action) { - case 'launch': - return await handleLaunch(command, browser); - case 'navigate': - return await handleNavigate(command, browser); - case 'click': - return await handleClick(command, browser); - case 'type': - return await handleType(command, browser); - case 'fill': - return await handleFill(command, browser); - case 'check': - return await handleCheck(command, browser); - case 'uncheck': - return await handleUncheck(command, browser); - case 'upload': - return await handleUpload(command, browser); - case 'dblclick': - return await handleDoubleClick(command, browser); - case 'focus': - return await handleFocus(command, browser); - case 'drag': - return await handleDrag(command, browser); - case 'frame': - return await handleFrame(command, browser); - case 'mainframe': - return await handleMainFrame(command, browser); - case 'getbyrole': - return await handleGetByRole(command, browser); - case 'getbytext': - return await handleGetByText(command, browser); - case 'getbylabel': - return await handleGetByLabel(command, browser); - case 'getbyplaceholder': - return await handleGetByPlaceholder(command, browser); - case 'press': - return await handlePress(command, browser); - case 'screenshot': - return await handleScreenshot(command, browser); - case 'snapshot': - return await handleSnapshot(command, browser); - case 'evaluate': - return await handleEvaluate(command, browser); - case 'wait': - return await handleWait(command, browser); - case 'scroll': - return await handleScroll(command, browser); - case 'select': - return await handleSelect(command, browser); - case 'hover': - return await handleHover(command, browser); - case 'content': - return await handleContent(command, browser); - case 'close': - return await handleClose(command, browser); - case 'tab_new': - return await handleTabNew(command, browser); - case 'tab_list': - return await handleTabList(command, browser); - case 'tab_switch': - return await handleTabSwitch(command, browser); - case 'tab_close': - return await handleTabClose(command, browser); - case 'window_new': - return await handleWindowNew(command, browser); - case 'cookies_get': - return await handleCookiesGet(command, browser); - case 'cookies_set': - return await handleCookiesSet(command, browser); - case 'cookies_clear': - return await handleCookiesClear(command, browser); - case 'storage_get': - return await handleStorageGet(command, browser); - case 'storage_set': - return await handleStorageSet(command, browser); - case 'storage_clear': - return await handleStorageClear(command, browser); - case 'dialog': - return await handleDialog(command, browser); - case 'pdf': - return await handlePdf(command, browser); - case 'route': - return await handleRoute(command, browser); - case 'unroute': - return await handleUnroute(command, browser); - case 'requests': - return await handleRequests(command, browser); - case 'download': - return await handleDownload(command, browser); - case 'geolocation': - return await handleGeolocation(command, browser); - case 'permissions': - return await handlePermissions(command, browser); - case 'viewport': - return await handleViewport(command, browser); - case 'useragent': - return await handleUserAgent(command, browser); - case 'device': - return await handleDevice(command, browser); - case 'back': - return await handleBack(command, browser); - case 'forward': - return await handleForward(command, browser); - case 'reload': - return await handleReload(command, browser); - case 'url': - return await handleUrl(command, browser); - case 'title': - return await handleTitle(command, browser); - case 'getattribute': - return await handleGetAttribute(command, browser); - case 'gettext': - return await handleGetText(command, browser); - case 'isvisible': - return await handleIsVisible(command, browser); - case 'isenabled': - return await handleIsEnabled(command, browser); - case 'ischecked': - return await handleIsChecked(command, browser); - case 'count': - return await handleCount(command, browser); - case 'boundingbox': - return await handleBoundingBox(command, browser); - case 'styles': - return await handleStyles(command, browser); - case 'video_start': - return await handleVideoStart(command, browser); - case 'video_stop': - return await handleVideoStop(command, browser); - case 'trace_start': - return await handleTraceStart(command, browser); - case 'trace_stop': - return await handleTraceStop(command, browser); - case 'profiler_start': - return await handleProfilerStart(command, browser); - case 'profiler_stop': - return await handleProfilerStop(command, browser); - case 'har_start': - return await handleHarStart(command, browser); - case 'har_stop': - return await handleHarStop(command, browser); - case 'state_save': - return await handleStateSave(command, browser); - case 'state_load': - return await handleStateLoad(command, browser); - case 'state_list': - return await handleStateList(command); - case 'state_clear': - return await handleStateClear(command); - case 'state_show': - return await handleStateShow(command); - case 'state_clean': - return await handleStateClean(command); - case 'state_rename': - return await handleStateRename(command); - case 'console': - return await handleConsole(command, browser); - case 'errors': - return await handleErrors(command, browser); - case 'keyboard': - return await handleKeyboard(command, browser); - case 'wheel': - return await handleWheel(command, browser); - case 'tap': - return await handleTap(command, browser); - case 'clipboard': - return await handleClipboard(command, browser); - case 'highlight': - return await handleHighlight(command, browser); - case 'clear': - return await handleClear(command, browser); - case 'selectall': - return await handleSelectAll(command, browser); - case 'innertext': - return await handleInnerText(command, browser); - case 'innerhtml': - return await handleInnerHtml(command, browser); - case 'inputvalue': - return await handleInputValue(command, browser); - case 'setvalue': - return await handleSetValue(command, browser); - case 'dispatch': - return await handleDispatch(command, browser); - case 'evalhandle': - return await handleEvalHandle(command, browser); - case 'expose': - return await handleExpose(command, browser); - case 'addscript': - return await handleAddScript(command, browser); - case 'addstyle': - return await handleAddStyle(command, browser); - case 'emulatemedia': - return await handleEmulateMedia(command, browser); - case 'offline': - return await handleOffline(command, browser); - case 'headers': - return await handleHeaders(command, browser); - case 'pause': - return await handlePause(command, browser); - case 'getbyalttext': - return await handleGetByAltText(command, browser); - case 'getbytitle': - return await handleGetByTitle(command, browser); - case 'getbytestid': - return await handleGetByTestId(command, browser); - case 'nth': - return await handleNth(command, browser); - case 'waitforurl': - return await handleWaitForUrl(command, browser); - case 'waitforloadstate': - return await handleWaitForLoadState(command, browser); - case 'setcontent': - return await handleSetContent(command, browser); - case 'timezone': - return await handleTimezone(command, browser); - case 'locale': - return await handleLocale(command, browser); - case 'credentials': - return await handleCredentials(command, browser); - case 'mousemove': - return await handleMouseMove(command, browser); - case 'mousedown': - return await handleMouseDown(command, browser); - case 'mouseup': - return await handleMouseUp(command, browser); - case 'bringtofront': - return await handleBringToFront(command, browser); - case 'waitforfunction': - return await handleWaitForFunction(command, browser); - case 'scrollintoview': - return await handleScrollIntoView(command, browser); - case 'addinitscript': - return await handleAddInitScript(command, browser); - case 'keydown': - return await handleKeyDown(command, browser); - case 'keyup': - return await handleKeyUp(command, browser); - case 'inserttext': - return await handleInsertText(command, browser); - case 'multiselect': - return await handleMultiSelect(command, browser); - case 'waitfordownload': - return await handleWaitForDownload(command, browser); - case 'responsebody': - return await handleResponseBody(command, browser); - case 'screencast_start': - return await handleScreencastStart(command, browser); - case 'screencast_stop': - return await handleScreencastStop(command, browser); - case 'input_mouse': - return await handleInputMouse(command, browser); - case 'input_keyboard': - return await handleInputKeyboard(command, browser); - case 'input_touch': - return await handleInputTouch(command, browser); - case 'recording_start': - return await handleRecordingStart(command, browser); - case 'recording_stop': - return await handleRecordingStop(command, browser); - case 'recording_restart': - return await handleRecordingRestart(command, browser); - case 'diff_snapshot': - return await handleDiffSnapshot(command, browser); - case 'diff_screenshot': - return await handleDiffScreenshot(command, browser); - case 'diff_url': - return await handleDiffUrl(command, browser); - default: { - // TypeScript narrows to never here, but we handle it for safety - const unknownCommand = command as { id: string; action: string }; - return errorResponse(unknownCommand.id, `Unknown action: ${unknownCommand.action}`); - } + // Handle confirm/deny actions (bypass policy check) + if (command.action === 'confirm') { + return await handleConfirm(command, browser); } + if (command.action === 'deny') { + return handleDeny(command); + } + + // Hot-reload policy file if it changed on disk + actionPolicy = reloadPolicyIfChanged(); + + // Policy enforcement + const decision = checkPolicy(command.action, actionPolicy, confirmCategories); + if (decision === 'deny') { + const category = getActionCategory(command.action); + return errorResponse(command.id, `Action denied by policy: '${category}' is not allowed`); + } + if (decision === 'confirm') { + const category = getActionCategory(command.action); + const description = describeAction( + command.action, + command as unknown as Record + ); + const { confirmationId } = requestConfirmation( + command.action, + category, + description, + command as unknown as Record + ); + return successResponse(command.id, { + confirmation_required: true, + action: command.action, + category, + description, + confirmation_id: confirmationId, + }); + } + + return await dispatchAction(command, browser); } catch (error) { const message = error instanceof Error ? error.message : String(error); return errorResponse(command.id, message); } } +/** + * Dispatch a command to its handler after policy checks have passed. + */ +async function dispatchAction(command: Command, browser: BrowserManager): Promise { + switch (command.action) { + case 'launch': + return await handleLaunch(command, browser); + case 'navigate': + return await handleNavigate(command, browser); + case 'click': + return await handleClick(command, browser); + case 'type': + return await handleType(command, browser); + case 'fill': + return await handleFill(command, browser); + case 'check': + return await handleCheck(command, browser); + case 'uncheck': + return await handleUncheck(command, browser); + case 'upload': + return await handleUpload(command, browser); + case 'dblclick': + return await handleDoubleClick(command, browser); + case 'focus': + return await handleFocus(command, browser); + case 'drag': + return await handleDrag(command, browser); + case 'frame': + return await handleFrame(command, browser); + case 'mainframe': + return await handleMainFrame(command, browser); + case 'getbyrole': + return await handleGetByRole(command, browser); + case 'getbytext': + return await handleGetByText(command, browser); + case 'getbylabel': + return await handleGetByLabel(command, browser); + case 'getbyplaceholder': + return await handleGetByPlaceholder(command, browser); + case 'press': + return await handlePress(command, browser); + case 'screenshot': + return await handleScreenshot(command, browser); + case 'snapshot': + return await handleSnapshot(command, browser); + case 'evaluate': + return await handleEvaluate(command, browser); + case 'wait': + return await handleWait(command, browser); + case 'scroll': + return await handleScroll(command, browser); + case 'select': + return await handleSelect(command, browser); + case 'hover': + return await handleHover(command, browser); + case 'content': + return await handleContent(command, browser); + case 'close': + return await handleClose(command, browser); + case 'tab_new': + return await handleTabNew(command, browser); + case 'tab_list': + return await handleTabList(command, browser); + case 'tab_switch': + return await handleTabSwitch(command, browser); + case 'tab_close': + return await handleTabClose(command, browser); + case 'window_new': + return await handleWindowNew(command, browser); + case 'cookies_get': + return await handleCookiesGet(command, browser); + case 'cookies_set': + return await handleCookiesSet(command, browser); + case 'cookies_clear': + return await handleCookiesClear(command, browser); + case 'storage_get': + return await handleStorageGet(command, browser); + case 'storage_set': + return await handleStorageSet(command, browser); + case 'storage_clear': + return await handleStorageClear(command, browser); + case 'dialog': + return await handleDialog(command, browser); + case 'pdf': + return await handlePdf(command, browser); + case 'route': + return await handleRoute(command, browser); + case 'unroute': + return await handleUnroute(command, browser); + case 'requests': + return await handleRequests(command, browser); + case 'download': + return await handleDownload(command, browser); + case 'geolocation': + return await handleGeolocation(command, browser); + case 'permissions': + return await handlePermissions(command, browser); + case 'viewport': + return await handleViewport(command, browser); + case 'useragent': + return await handleUserAgent(command, browser); + case 'device': + return await handleDevice(command, browser); + case 'back': + return await handleBack(command, browser); + case 'forward': + return await handleForward(command, browser); + case 'reload': + return await handleReload(command, browser); + case 'url': + return await handleUrl(command, browser); + case 'title': + return await handleTitle(command, browser); + case 'getattribute': + return await handleGetAttribute(command, browser); + case 'gettext': + return await handleGetText(command, browser); + case 'isvisible': + return await handleIsVisible(command, browser); + case 'isenabled': + return await handleIsEnabled(command, browser); + case 'ischecked': + return await handleIsChecked(command, browser); + case 'count': + return await handleCount(command, browser); + case 'boundingbox': + return await handleBoundingBox(command, browser); + case 'styles': + return await handleStyles(command, browser); + case 'video_start': + return await handleVideoStart(command, browser); + case 'video_stop': + return await handleVideoStop(command, browser); + case 'trace_start': + return await handleTraceStart(command, browser); + case 'trace_stop': + return await handleTraceStop(command, browser); + case 'profiler_start': + return await handleProfilerStart(command, browser); + case 'profiler_stop': + return await handleProfilerStop(command, browser); + case 'har_start': + return await handleHarStart(command, browser); + case 'har_stop': + return await handleHarStop(command, browser); + case 'state_save': + return await handleStateSave(command, browser); + case 'state_load': + return await handleStateLoad(command, browser); + case 'state_list': + return await handleStateList(command); + case 'state_clear': + return await handleStateClear(command); + case 'state_show': + return await handleStateShow(command); + case 'state_clean': + return await handleStateClean(command); + case 'state_rename': + return await handleStateRename(command); + case 'console': + return await handleConsole(command, browser); + case 'errors': + return await handleErrors(command, browser); + case 'keyboard': + return await handleKeyboard(command, browser); + case 'wheel': + return await handleWheel(command, browser); + case 'tap': + return await handleTap(command, browser); + case 'clipboard': + return await handleClipboard(command, browser); + case 'highlight': + return await handleHighlight(command, browser); + case 'clear': + return await handleClear(command, browser); + case 'selectall': + return await handleSelectAll(command, browser); + case 'innertext': + return await handleInnerText(command, browser); + case 'innerhtml': + return await handleInnerHtml(command, browser); + case 'inputvalue': + return await handleInputValue(command, browser); + case 'setvalue': + return await handleSetValue(command, browser); + case 'dispatch': + return await handleDispatch(command, browser); + case 'evalhandle': + return await handleEvalHandle(command, browser); + case 'expose': + return await handleExpose(command, browser); + case 'addscript': + return await handleAddScript(command, browser); + case 'addstyle': + return await handleAddStyle(command, browser); + case 'emulatemedia': + return await handleEmulateMedia(command, browser); + case 'offline': + return await handleOffline(command, browser); + case 'headers': + return await handleHeaders(command, browser); + case 'pause': + return await handlePause(command, browser); + case 'getbyalttext': + return await handleGetByAltText(command, browser); + case 'getbytitle': + return await handleGetByTitle(command, browser); + case 'getbytestid': + return await handleGetByTestId(command, browser); + case 'nth': + return await handleNth(command, browser); + case 'waitforurl': + return await handleWaitForUrl(command, browser); + case 'waitforloadstate': + return await handleWaitForLoadState(command, browser); + case 'setcontent': + return await handleSetContent(command, browser); + case 'timezone': + return await handleTimezone(command, browser); + case 'locale': + return await handleLocale(command, browser); + case 'credentials': + return await handleCredentials(command, browser); + case 'mousemove': + return await handleMouseMove(command, browser); + case 'mousedown': + return await handleMouseDown(command, browser); + case 'mouseup': + return await handleMouseUp(command, browser); + case 'bringtofront': + return await handleBringToFront(command, browser); + case 'waitforfunction': + return await handleWaitForFunction(command, browser); + case 'scrollintoview': + return await handleScrollIntoView(command, browser); + case 'addinitscript': + return await handleAddInitScript(command, browser); + case 'keydown': + return await handleKeyDown(command, browser); + case 'keyup': + return await handleKeyUp(command, browser); + case 'inserttext': + return await handleInsertText(command, browser); + case 'multiselect': + return await handleMultiSelect(command, browser); + case 'waitfordownload': + return await handleWaitForDownload(command, browser); + case 'responsebody': + return await handleResponseBody(command, browser); + case 'screencast_start': + return await handleScreencastStart(command, browser); + case 'screencast_stop': + return await handleScreencastStop(command, browser); + case 'input_mouse': + return await handleInputMouse(command, browser); + case 'input_keyboard': + return await handleInputKeyboard(command, browser); + case 'input_touch': + return await handleInputTouch(command, browser); + case 'recording_start': + return await handleRecordingStart(command, browser); + case 'recording_stop': + return await handleRecordingStop(command, browser); + case 'recording_restart': + return await handleRecordingRestart(command, browser); + case 'diff_snapshot': + return await handleDiffSnapshot(command, browser); + case 'diff_screenshot': + return await handleDiffScreenshot(command, browser); + case 'diff_url': + return await handleDiffUrl(command, browser); + case 'auth_login': + return await handleAuthLogin(command, browser); + default: { + // TypeScript narrows to never here, but we handle it for safety + const unknownCommand = command as { id: string; action: string }; + return errorResponse(unknownCommand.id, `Unknown action: ${unknownCommand.action}`); + } + } +} + async function handleLaunch( command: Command & { action: 'launch' }, browser: BrowserManager @@ -524,6 +613,8 @@ async function handleNavigate( command: NavigateCommand, browser: BrowserManager ): Promise> { + browser.checkDomainAllowed(command.url); + const page = browser.getPage(); // If headers are provided, set up scoped headers for this origin @@ -843,9 +934,11 @@ async function handleSnapshot( simpleRefs[ref] = { role: data.role, name: data.name }; } + const page = browser.getPage(); return successResponse(command.id, { snapshot: tree || 'Empty page', refs: Object.keys(simpleRefs).length > 0 ? simpleRefs : undefined, + origin: page.url(), }); } @@ -858,7 +951,7 @@ async function handleEvaluate( // Evaluate the script directly as a string expression const result = await page.evaluate(command.script); - return successResponse(command.id, { result }); + return successResponse(command.id, { result, origin: page.url() }); } async function handleWait(command: WaitCommand, browser: BrowserManager): Promise { @@ -960,7 +1053,7 @@ async function handleContent( html = await page.content(); } - return successResponse(command.id, { html }); + return successResponse(command.id, { html, origin: page.url() }); } async function handleClose( @@ -1472,15 +1565,17 @@ async function handleGetAttribute( command: GetAttributeCommand, browser: BrowserManager ): Promise { + const page = browser.getPage(); const locator = browser.getLocator(command.selector); const value = await locator.getAttribute(command.attribute); - return successResponse(command.id, { attribute: command.attribute, value }); + return successResponse(command.id, { attribute: command.attribute, value, origin: page.url() }); } async function handleGetText(command: GetTextCommand, browser: BrowserManager): Promise { + const page = browser.getPage(); const locator = browser.getLocator(command.selector); const text = await locator.textContent(); - return successResponse(command.id, { text }); + return successResponse(command.id, { text, origin: page.url() }); } async function handleIsVisible( @@ -1875,8 +1970,9 @@ async function handleConsole(command: ConsoleCommand, browser: BrowserManager): return successResponse(command.id, { cleared: true }); } + const page = browser.getPage(); const messages = browser.getConsoleMessages(); - return successResponse(command.id, { messages }); + return successResponse(command.id, { messages, origin: page.url() }); } async function handleErrors(command: ErrorsCommand, browser: BrowserManager): Promise { @@ -1989,16 +2085,17 @@ async function handleInnerHtml( ): Promise { const page = browser.getPage(); const html = await page.locator(command.selector).innerHTML(); - return successResponse(command.id, { html }); + return successResponse(command.id, { html, origin: page.url() }); } async function handleInputValue( command: InputValueCommand, browser: BrowserManager ): Promise { + const page = browser.getPage(); const locator = browser.getLocator(command.selector); const value = await locator.inputValue(); - return successResponse(command.id, { value }); + return successResponse(command.id, { value, origin: page.url() }); } async function handleSetValue( @@ -2597,3 +2694,139 @@ async function handleDiffUrl(command: DiffUrlCommand, browser: BrowserManager): return successResponse(command.id, result); } + +async function handleAuthLogin( + command: AuthLoginCommand, + browser: BrowserManager +): Promise { + const profile = getAuthProfile(command.name); + if (!profile) { + return errorResponse(command.id, `Auth profile '${command.name}' not found`); + } + + browser.checkDomainAllowed(profile.url); + + const page = browser.getPage(); + await page.goto(profile.url, { waitUntil: 'load' }); + + const usingAutoDetect = + !profile.usernameSelector && !profile.passwordSelector && !profile.submitSelector; + if (usingAutoDetect) { + console.error( + `[agent-browser] Auth login '${command.name}': using auto-detected form selectors. ` + + `If login fails, specify --username-selector/--password-selector/--submit-selector with auth save.` + ); + } + + const passSel = profile.passwordSelector || 'input[type="password"]:visible'; + + // Auto-detect selectors ordered from most specific to broadest. + // Locale-dependent text matchers (e.g. "Sign in") are intentionally + // excluded -- they break on non-English pages. + const AUTO_USER_SELECTORS = [ + 'input[autocomplete="username"]:visible', + 'input[type="email"]:visible', + 'input[name="username"]:visible', + 'input[name="email"]:visible', + ]; + const AUTO_SUBMIT_SELECTORS = ['button[type="submit"]:visible', 'input[type="submit"]:visible']; + + try { + // Resolve username field: custom selector or sequential auto-detect + let userLocator; + if (profile.usernameSelector) { + userLocator = page.locator(profile.usernameSelector).first(); + } else { + userLocator = null; + for (const sel of AUTO_USER_SELECTORS) { + const loc = page.locator(sel).first(); + if (await loc.isVisible({ timeout: 1000 }).catch(() => false)) { + userLocator = loc; + break; + } + } + if (!userLocator) { + return errorResponse( + command.id, + `Auth login failed for '${command.name}': could not find username field. ` + + `Specify --username-selector with auth save.` + ); + } + } + + // Resolve submit button: custom selector or sequential auto-detect + let submitLocator; + if (profile.submitSelector) { + submitLocator = page.locator(profile.submitSelector).first(); + } else { + submitLocator = null; + for (const sel of AUTO_SUBMIT_SELECTORS) { + const loc = page.locator(sel).first(); + if (await loc.isVisible({ timeout: 1000 }).catch(() => false)) { + submitLocator = loc; + break; + } + } + if (!submitLocator) { + return errorResponse( + command.id, + `Auth login failed for '${command.name}': could not find submit button. ` + + `Specify --submit-selector with auth save.` + ); + } + } + + await userLocator.fill(profile.username); + await page.locator(passSel).first().fill(profile.password); + await submitLocator.click(); + await page.waitForLoadState('load'); + } catch (err) { + return errorResponse( + command.id, + `Auth login failed for '${command.name}': ${err instanceof Error ? err.message : err}. ` + + `Try specifying custom selectors with auth save --username-selector/--password-selector/--submit-selector` + ); + } + + updateLastLogin(command.name); + + return successResponse(command.id, { + loggedIn: true, + name: command.name, + url: page.url(), + title: await page.title(), + }); +} + +async function handleConfirm(command: ConfirmCommand, browser: BrowserManager): Promise { + const entry = getAndRemovePending(command.confirmationId); + if (!entry) { + return errorResponse(command.id, `No pending confirmation with id '${command.confirmationId}'`); + } + + // Re-validate the stored command through the schema to guard against + // shape drift between when the confirmation was issued and now. + const parseResult = parseCommand(JSON.stringify(entry.command)); + if (!parseResult.success) { + return errorResponse(command.id, `Stored command is no longer valid: ${parseResult.error}`); + } + const originalCommand = parseResult.command; + + // Re-check deny list in case policy was updated since the confirmation was issued + actionPolicy = reloadPolicyIfChanged(); + const decision = checkPolicy(originalCommand.action, actionPolicy, new Set()); + if (decision === 'deny') { + const category = getActionCategory(originalCommand.action); + return errorResponse(command.id, `Action denied by policy: '${category}' is not allowed`); + } + + return await dispatchAction(originalCommand, browser); +} + +function handleDeny(command: DenyCommand): Response { + const entry = getAndRemovePending(command.confirmationId); + if (!entry) { + return errorResponse(command.id, `No pending confirmation with id '${command.confirmationId}'`); + } + return successResponse(command.id, { denied: true }); +} diff --git a/src/auth-cli.ts b/src/auth-cli.ts new file mode 100644 index 0000000..61f3df0 --- /dev/null +++ b/src/auth-cli.ts @@ -0,0 +1,120 @@ +/** + * Standalone CLI entry point for auth vault operations that don't need a browser. + * Invoked directly by the Rust CLI to avoid sending passwords through the daemon channel. + * + * Usage: node auth-cli.js + * Prints a JSON response to stdout and exits. + */ +import { + saveAuthProfile, + getAuthProfileMeta, + listAuthProfiles, + deleteAuthProfile, +} from './auth-vault.js'; + +interface AuthCommand { + id: string; + action: string; + name?: string; + url?: string; + username?: string; + password?: string; + usernameSelector?: string; + passwordSelector?: string; + submitSelector?: string; +} + +function success(id: string, data: Record): string { + return JSON.stringify({ success: true, id, data }); +} + +function error(id: string, message: string): string { + return JSON.stringify({ success: false, id, error: message }); +} + +function run(): void { + const input = process.argv[2]; + if (!input) { + process.stderr.write('Usage: node auth-cli.js \n'); + process.exit(1); + } + + let cmd: AuthCommand; + try { + cmd = JSON.parse(input); + } catch { + console.log(error('', 'Invalid JSON input')); + process.exit(1); + return; + } + + const id = cmd.id || ''; + + try { + switch (cmd.action) { + case 'auth_save': { + if (!cmd.name || !cmd.url || !cmd.username || !cmd.password) { + console.log(error(id, 'Missing required fields: name, url, username, password')); + return; + } + const meta = saveAuthProfile({ + name: cmd.name, + url: cmd.url, + username: cmd.username, + password: cmd.password, + usernameSelector: cmd.usernameSelector, + passwordSelector: cmd.passwordSelector, + submitSelector: cmd.submitSelector, + }); + console.log( + success(id, { + saved: !meta.updated, + updated: meta.updated, + name: meta.name, + url: meta.url, + username: meta.username, + }) + ); + return; + } + case 'auth_list': { + const profiles = listAuthProfiles(); + console.log(success(id, { profiles })); + return; + } + case 'auth_show': { + if (!cmd.name) { + console.log(error(id, 'Missing required field: name')); + return; + } + const meta = getAuthProfileMeta(cmd.name); + if (!meta) { + console.log(error(id, `Auth profile '${cmd.name}' not found`)); + return; + } + console.log(success(id, { profile: meta })); + return; + } + case 'auth_delete': { + if (!cmd.name) { + console.log(error(id, 'Missing required field: name')); + return; + } + const deleted = deleteAuthProfile(cmd.name); + if (!deleted) { + console.log(error(id, `Auth profile '${cmd.name}' not found`)); + return; + } + console.log(success(id, { deleted: true, name: cmd.name })); + return; + } + default: + console.log(error(id, `Unknown auth action: ${cmd.action}`)); + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Operation failed'; + console.log(error(id, msg)); + } +} + +run(); diff --git a/src/auth-vault.test.ts b/src/auth-vault.test.ts new file mode 100644 index 0000000..5e298fe --- /dev/null +++ b/src/auth-vault.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +let tempHome: string; + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { + ...actual, + homedir: () => tempHome, + }, + homedir: () => tempHome, + }; +}); + +import { + saveAuthProfile, + getAuthProfile, + getAuthProfileMeta, + listAuthProfiles, + deleteAuthProfile, + updateLastLogin, +} from './auth-vault.js'; + +describe('auth-vault', () => { + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-browser-auth-test-')); + delete process.env.AGENT_BROWSER_ENCRYPTION_KEY; + }); + + afterEach(() => { + try { + fs.rmSync(tempHome, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + }); + + function cleanAuthDir() { + const authDir = path.join(tempHome, '.agent-browser', 'auth'); + if (fs.existsSync(authDir)) { + for (const f of fs.readdirSync(authDir)) { + fs.unlinkSync(path.join(authDir, f)); + } + } + } + + describe('saveAuthProfile', () => { + it('should save a new profile', () => { + const result = saveAuthProfile({ + name: 'github', + url: 'https://github.com/login', + username: 'user', + password: 'pass', + }); + + expect(result.name).toBe('github'); + expect(result.url).toBe('https://github.com/login'); + expect(result.username).toBe('user'); + expect(result.updated).toBe(false); + expect(result.createdAt).toBeTruthy(); + }); + + it('should mark as updated when overwriting', () => { + saveAuthProfile({ + name: 'github', + url: 'https://github.com/login', + username: 'user1', + password: 'pass1', + }); + + const result = saveAuthProfile({ + name: 'github', + url: 'https://github.com/login', + username: 'user2', + password: 'pass2', + }); + + expect(result.updated).toBe(true); + expect(result.username).toBe('user2'); + }); + + it('should preserve createdAt on update', () => { + const first = saveAuthProfile({ + name: 'github', + url: 'https://github.com/login', + username: 'user', + password: 'pass', + }); + + const second = saveAuthProfile({ + name: 'github', + url: 'https://github.com/login', + username: 'user2', + password: 'pass2', + }); + + expect(second.createdAt).toBe(first.createdAt); + }); + + it('should save with custom selectors', () => { + saveAuthProfile({ + name: 'myapp', + url: 'https://example.com/login', + username: 'user', + password: 'pass', + usernameSelector: '#email', + passwordSelector: '#password', + submitSelector: 'button.login', + }); + + const profile = getAuthProfile('myapp'); + expect(profile).not.toBeNull(); + expect(profile!.usernameSelector).toBe('#email'); + expect(profile!.passwordSelector).toBe('#password'); + expect(profile!.submitSelector).toBe('button.login'); + }); + + it('should reject invalid profile names', () => { + expect(() => + saveAuthProfile({ + name: '../escape', + url: 'https://example.com', + username: 'user', + password: 'pass', + }) + ).toThrow('only alphanumeric'); + }); + }); + + describe('getAuthProfile', () => { + it('should return null for non-existent profile', () => { + expect(getAuthProfile('nonexistent')).toBeNull(); + }); + + it('should return full profile with password', () => { + saveAuthProfile({ + name: 'test', + url: 'https://example.com', + username: 'user', + password: 'secret', + }); + + const profile = getAuthProfile('test'); + expect(profile).not.toBeNull(); + expect(profile!.password).toBe('secret'); + }); + }); + + describe('getAuthProfileMeta', () => { + it('should return metadata without password', () => { + saveAuthProfile({ + name: 'test', + url: 'https://example.com', + username: 'user', + password: 'secret', + }); + + const meta = getAuthProfileMeta('test'); + expect(meta).not.toBeNull(); + expect(meta!.name).toBe('test'); + expect(meta!.username).toBe('user'); + expect((meta as Record).password).toBeUndefined(); + }); + + it('should return null for non-existent profile', () => { + expect(getAuthProfileMeta('nonexistent')).toBeNull(); + }); + }); + + describe('listAuthProfiles', () => { + it('should return empty array when no profiles', () => { + cleanAuthDir(); + expect(listAuthProfiles()).toEqual([]); + }); + + it('should list all saved profiles', () => { + cleanAuthDir(); + saveAuthProfile({ + name: 'github', + url: 'https://github.com/login', + username: 'user1', + password: 'pass1', + }); + saveAuthProfile({ + name: 'gitlab', + url: 'https://gitlab.com/login', + username: 'user2', + password: 'pass2', + }); + + const profiles = listAuthProfiles(); + expect(profiles).toHaveLength(2); + const names = profiles.map((p) => p.name).sort(); + expect(names).toEqual(['github', 'gitlab']); + }); + }); + + describe('deleteAuthProfile', () => { + it('should delete an existing profile', () => { + saveAuthProfile({ + name: 'test', + url: 'https://example.com', + username: 'user', + password: 'pass', + }); + + expect(deleteAuthProfile('test')).toBe(true); + expect(getAuthProfile('test')).toBeNull(); + }); + + it('should return false for non-existent profile', () => { + expect(deleteAuthProfile('nonexistent')).toBe(false); + }); + }); + + describe('updateLastLogin', () => { + it('should update lastLoginAt timestamp', () => { + saveAuthProfile({ + name: 'test', + url: 'https://example.com', + username: 'user', + password: 'pass', + }); + + const metaBefore = getAuthProfileMeta('test'); + expect(metaBefore!.lastLoginAt).toBeUndefined(); + + updateLastLogin('test'); + + const metaAfter = getAuthProfileMeta('test'); + expect(metaAfter!.lastLoginAt).toBeTruthy(); + }); + }); + + describe('auto-generated encryption key', () => { + it('should auto-create key file and encrypt profile when no env var is set', () => { + delete process.env.AGENT_BROWSER_ENCRYPTION_KEY; + + saveAuthProfile({ + name: 'autokey', + url: 'https://example.com', + username: 'user', + password: 'secret', + }); + + const keyFilePath = path.join(tempHome, '.agent-browser', '.encryption-key'); + expect(fs.existsSync(keyFilePath)).toBe(true); + + const keyHex = fs.readFileSync(keyFilePath, 'utf-8').trim(); + expect(keyHex).toMatch(/^[a-f0-9]{64}$/); + + const profilePath = path.join(tempHome, '.agent-browser', 'auth', 'autokey.json'); + const raw = JSON.parse(fs.readFileSync(profilePath, 'utf-8')); + expect(raw.encrypted).toBe(true); + expect(raw.iv).toBeTruthy(); + }); + + it('should read back profile using auto-generated key', () => { + delete process.env.AGENT_BROWSER_ENCRYPTION_KEY; + + saveAuthProfile({ + name: 'readback', + url: 'https://example.com', + username: 'user', + password: 'secret123', + }); + + const profile = getAuthProfile('readback'); + expect(profile).not.toBeNull(); + expect(profile!.password).toBe('secret123'); + }); + }); +}); diff --git a/src/auth-vault.ts b/src/auth-vault.ts new file mode 100644 index 0000000..577b4fd --- /dev/null +++ b/src/auth-vault.ts @@ -0,0 +1,189 @@ +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + readdirSync, + unlinkSync, +} from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { + getEncryptionKey, + ensureEncryptionKey, + encryptData, + decryptData, + isEncryptedPayload, + getKeyFilePath, + restrictFilePermissions, + restrictDirPermissions, + type EncryptedPayload, +} from './encryption.js'; + +const AUTH_DIR = 'auth'; + +interface AuthProfile { + name: string; + url: string; + username: string; + password: string; + usernameSelector?: string; + passwordSelector?: string; + submitSelector?: string; + createdAt: string; + lastLoginAt?: string; +} + +export interface AuthProfileMeta { + name: string; + url: string; + username: string; + createdAt: string; + lastLoginAt?: string; +} + +function getAuthDir(): string { + const dir = path.join(os.homedir(), '.agent-browser', AUTH_DIR); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + restrictDirPermissions(dir); + } + return dir; +} + +const SAFE_NAME_RE = /^[a-zA-Z0-9_-]+$/; + +function validateProfileName(name: string): void { + if (!SAFE_NAME_RE.test(name)) { + throw new Error( + `Invalid auth profile name '${name}': only alphanumeric characters, hyphens, and underscores are allowed` + ); + } +} + +function profilePath(name: string): string { + validateProfileName(name); + return path.join(getAuthDir(), `${name}.json`); +} + +function readProfile(name: string): AuthProfile | null { + const p = profilePath(name); + if (!existsSync(p)) return null; + + const raw = readFileSync(p, 'utf-8'); + const parsed = JSON.parse(raw); + + if (isEncryptedPayload(parsed)) { + const key = getEncryptionKey(); + if (!key) { + throw new Error( + `Encryption key required to read encrypted auth profiles. ` + + `Set AGENT_BROWSER_ENCRYPTION_KEY or ensure ${getKeyFilePath()} exists.` + ); + } + const decrypted = decryptData(parsed as EncryptedPayload, key); + return JSON.parse(decrypted) as AuthProfile; + } + + return parsed as AuthProfile; +} + +function writeProfile(profile: AuthProfile): void { + const key = ensureEncryptionKey(); + const serialized = JSON.stringify(profile, null, 2); + const encrypted = encryptData(serialized, key); + const filePath = profilePath(profile.name); + writeFileSync(filePath, JSON.stringify(encrypted, null, 2), { + mode: 0o600, + }); + restrictFilePermissions(filePath); +} + +export function saveAuthProfile(opts: { + name: string; + url: string; + username: string; + password: string; + usernameSelector?: string; + passwordSelector?: string; + submitSelector?: string; +}): AuthProfileMeta & { updated: boolean } { + const existing = readProfile(opts.name); + + const profile: AuthProfile = { + name: opts.name, + url: opts.url, + username: opts.username, + password: opts.password, + usernameSelector: opts.usernameSelector, + passwordSelector: opts.passwordSelector, + submitSelector: opts.submitSelector, + createdAt: existing?.createdAt ?? new Date().toISOString(), + lastLoginAt: existing?.lastLoginAt, + }; + + writeProfile(profile); + + return { + name: profile.name, + url: profile.url, + username: profile.username, + createdAt: profile.createdAt, + lastLoginAt: profile.lastLoginAt, + updated: existing !== null, + }; +} + +export function getAuthProfile(name: string): AuthProfile | null { + return readProfile(name); +} + +export function getAuthProfileMeta(name: string): AuthProfileMeta | null { + const profile = readProfile(name); + if (!profile) return null; + return { + name: profile.name, + url: profile.url, + username: profile.username, + createdAt: profile.createdAt, + lastLoginAt: profile.lastLoginAt, + }; +} + +export function listAuthProfiles(): AuthProfileMeta[] { + const dir = getAuthDir(); + const files = readdirSync(dir).filter((f) => f.endsWith('.json')); + const profiles: AuthProfileMeta[] = []; + + for (const file of files) { + const name = file.replace(/\.json$/, ''); + try { + const meta = getAuthProfileMeta(name); + if (meta) profiles.push(meta); + } catch { + profiles.push({ + name, + url: '(encrypted)', + username: '(encrypted)', + createdAt: '(unknown)', + }); + } + } + + return profiles; +} + +export function deleteAuthProfile(name: string): boolean { + const p = profilePath(name); + if (!existsSync(p)) return false; + unlinkSync(p); + return true; +} + +export function updateLastLogin(name: string): void { + const profile = readProfile(name); + if (profile) { + profile.lastLoginAt = new Date().toISOString(); + writeProfile(profile); + } +} diff --git a/src/browser.ts b/src/browser.ts index 93538d1..c32aea3 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -21,6 +21,7 @@ import { writeFile, mkdir } from 'node:fs/promises'; import type { LaunchCommand, TraceEvent } from './types.js'; import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js'; import { safeHeaderMerge } from './state-utils.js'; +import { isDomainAllowed, installDomainFilter, parseDomainList } from './domain-filter.js'; import { getEncryptionKey, isEncryptedPayload, @@ -117,6 +118,7 @@ export class BrowserManager { private scopedHeaderRoutes: Map Promise> = new Map(); private colorScheme: 'light' | 'dark' | 'no-preference' | null = null; private downloadPath: string | null = null; + private allowedDomains: string[] = []; /** * Set the persistent color scheme preference. @@ -246,6 +248,61 @@ export class BrowserManager { return parseRef(selector) !== null; } + /** + * Install the domain filter on a context if an allowlist is configured. + * Should be called before any pages navigate on the context. + */ + private async ensureDomainFilter(context: BrowserContext): Promise { + if (this.allowedDomains.length > 0) { + await installDomainFilter(context, this.allowedDomains); + } + } + + /** + * After installing the domain filter, verify existing pages are on allowed + * domains. Pages that pre-date the filter (e.g. CDP/cloud connect) may have + * already navigated to disallowed domains. Navigate them to about:blank. + */ + private async sanitizeExistingPages(pages: Page[]): Promise { + if (this.allowedDomains.length === 0) return; + for (const page of pages) { + const url = page.url(); + if (!url || url === 'about:blank') continue; + try { + const hostname = new URL(url).hostname.toLowerCase(); + if (!isDomainAllowed(hostname, this.allowedDomains)) { + await page.goto('about:blank'); + } + } catch { + await page.goto('about:blank').catch(() => {}); + } + } + } + + /** + * Check if a URL is allowed by the domain allowlist. + * Throws if the URL's domain is blocked. No-op if no allowlist is set. + * Blocks non-http(s) schemes and unparseable URLs by default. + */ + checkDomainAllowed(url: string): void { + if (this.allowedDomains.length === 0) return; + + if (!url.startsWith('http://') && !url.startsWith('https://')) { + throw new Error(`Navigation blocked: non-http(s) scheme in URL "${url}"`); + } + + let hostname: string; + try { + hostname = new URL(url).hostname.toLowerCase(); + } catch { + throw new Error(`Navigation blocked: unable to parse URL "${url}"`); + } + + if (!isDomainAllowed(hostname, this.allowedDomains)) { + throw new Error(`Navigation blocked: ${hostname} is not in the allowed domains list`); + } + } + /** * Get locator - supports both refs and regular selectors */ @@ -286,6 +343,7 @@ export class BrowserManager { context.setDefaultTimeout(getDefaultTimeout()); this.contexts.push(context); this.setupContextTracking(context); + await this.ensureDomainFilter(context); } else { return; } @@ -899,6 +957,8 @@ export class BrowserManager { context.setDefaultTimeout(10000); this.contexts.push(context); this.setupContextTracking(context); + await this.ensureDomainFilter(context); + await this.sanitizeExistingPages([page]); this.pages.push(page); this.activePageIndex = 0; this.setupPageTracking(page); @@ -1039,10 +1099,12 @@ export class BrowserManager { this.browser = browser; context.setDefaultTimeout(getDefaultTimeout()); this.contexts.push(context); + this.setupContextTracking(context); + await this.ensureDomainFilter(context); + await this.sanitizeExistingPages([page]); this.pages.push(page); this.activePageIndex = 0; this.setupPageTracking(page); - this.setupContextTracking(context); } catch (error) { await this.closeKernelSession(session.session_id, kernelApiKey).catch((sessionError) => { console.error('Failed to close Kernel session during cleanup:', sessionError); @@ -1112,10 +1174,12 @@ export class BrowserManager { this.browser = browser; context.setDefaultTimeout(getDefaultTimeout()); this.contexts.push(context); + this.setupContextTracking(context); + await this.ensureDomainFilter(context); + await this.sanitizeExistingPages([page]); this.pages.push(page); this.activePageIndex = 0; this.setupPageTracking(page); - this.setupContextTracking(context); } catch (error) { await this.closeBrowserUseSession(session.id, browserUseApiKey).catch((sessionError) => { console.error('Failed to close Browser Use session during cleanup:', sessionError); @@ -1178,6 +1242,15 @@ export class BrowserManager { this.downloadPath = options.downloadPath; } + if (options.allowedDomains && options.allowedDomains.length > 0) { + this.allowedDomains = options.allowedDomains.map((d: string) => d.toLowerCase()); + } else { + const envDomains = process.env.AGENT_BROWSER_ALLOWED_DOMAINS; + if (envDomains) { + this.allowedDomains = parseDomainList(envDomains); + } + } + if (this.downloadPath && (cdpEndpoint || options.autoConnect)) { const warning = "--download-path is ignored when connecting via CDP or auto-connect (downloads use the remote browser's configuration)"; @@ -1405,8 +1478,10 @@ export class BrowserManager { context.setDefaultTimeout(getDefaultTimeout()); this.contexts.push(context); this.setupContextTracking(context); + await this.ensureDomainFilter(context); const page = context.pages()[0] ?? (await context.newPage()); + await this.sanitizeExistingPages([page]); // Only add if not already tracked (setupContextTracking may have already added it via 'page' event) if (!this.pages.includes(page)) { this.pages.push(page); @@ -1480,8 +1555,11 @@ export class BrowserManager { context.setDefaultTimeout(10000); this.contexts.push(context); this.setupContextTracking(context); + await this.ensureDomainFilter(context); } + await this.sanitizeExistingPages(allPages); + for (const page of allPages) { this.pages.push(page); this.setupPageTracking(page); @@ -1737,6 +1815,7 @@ export class BrowserManager { context.setDefaultTimeout(getDefaultTimeout()); this.contexts.push(context); this.setupContextTracking(context); + await this.ensureDomainFilter(context); const page = await context.newPage(); // Only add if not already tracked (setupContextTracking may have already added it via 'page' event) diff --git a/src/confirmation.test.ts b/src/confirmation.test.ts new file mode 100644 index 0000000..f2553ab --- /dev/null +++ b/src/confirmation.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { requestConfirmation, getAndRemovePending } from './confirmation.js'; + +describe('confirmation', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('requestConfirmation', () => { + it('should return a confirmation ID', () => { + const result = requestConfirmation('evaluate', 'eval', 'Evaluate JS', { script: 'test' }); + expect(result.confirmationId).toBeTruthy(); + expect(result.confirmationId).toMatch(/^c_[0-9a-f]{16}$/); + }); + + it('should generate unique IDs', () => { + const r1 = requestConfirmation('evaluate', 'eval', 'desc', {}); + const r2 = requestConfirmation('click', 'click', 'desc', {}); + expect(r1.confirmationId).not.toBe(r2.confirmationId); + }); + }); + + describe('getAndRemovePending', () => { + it('should retrieve and remove a pending confirmation', () => { + const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', { + action: 'evaluate', + script: 'test', + }); + + const entry = getAndRemovePending(confirmationId); + expect(entry).not.toBeNull(); + expect(entry!.action).toBe('evaluate'); + expect(entry!.command).toEqual({ action: 'evaluate', script: 'test' }); + }); + + it('should return null on second retrieval (already removed)', () => { + const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {}); + getAndRemovePending(confirmationId); + expect(getAndRemovePending(confirmationId)).toBeNull(); + }); + + it('should return null for non-existent ID', () => { + expect(getAndRemovePending('c_nonexistent')).toBeNull(); + }); + + it('should auto-deny after 60 seconds', () => { + const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {}); + + vi.advanceTimersByTime(60_000); + + expect(getAndRemovePending(confirmationId)).toBeNull(); + }); + + it('should still be retrievable before 60 second timeout', () => { + const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {}); + + vi.advanceTimersByTime(59_999); + + const entry = getAndRemovePending(confirmationId); + expect(entry).not.toBeNull(); + }); + }); +}); diff --git a/src/confirmation.ts b/src/confirmation.ts new file mode 100644 index 0000000..04b00cf --- /dev/null +++ b/src/confirmation.ts @@ -0,0 +1,53 @@ +import { randomBytes } from 'node:crypto'; + +interface PendingConfirmation { + id: string; + action: string; + category: string; + description: string; + command: Record; + timer: ReturnType; +} + +const AUTO_DENY_TIMEOUT_MS = 60_000; + +const pending = new Map(); + +function generateId(): string { + return `c_${randomBytes(8).toString('hex')}`; +} + +export function requestConfirmation( + action: string, + category: string, + description: string, + command: Record +): { confirmationId: string } { + const id = generateId(); + + const timer = setTimeout(() => { + pending.delete(id); + }, AUTO_DENY_TIMEOUT_MS); + + pending.set(id, { + id, + action, + category, + description, + command, + timer, + }); + + return { confirmationId: id }; +} + +export function getAndRemovePending( + id: string +): { command: Record; action: string } | null { + const entry = pending.get(id); + if (!entry) return null; + + clearTimeout(entry.timer); + pending.delete(id); + return { command: entry.command, action: entry.action }; +} diff --git a/src/daemon.ts b/src/daemon.ts index 9310ef0..c47a7ff 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -5,7 +5,7 @@ import * as os from 'os'; import { BrowserManager } from './browser.js'; import { IOSManager } from './ios-manager.js'; import { parseCommand, serializeResponse, errorResponse } from './protocol.js'; -import { executeCommand } from './actions.js'; +import { executeCommand, initActionPolicy } from './actions.js'; import { executeIOSCommand } from './ios-actions.js'; import { StreamServer } from './stream-server.js'; import { @@ -333,6 +333,9 @@ export async function startDaemon(options?: { // Clean up expired state files on startup runCleanupExpiredStates(); + // Initialize action policy enforcement + initActionPolicy(); + // Determine provider from options or environment const provider = options?.provider ?? process.env.AGENT_BROWSER_PROVIDER; const isIOS = provider === 'ios'; @@ -595,9 +598,13 @@ export async function startDaemon(options?: { processQueue().catch((err) => { // Socket write failures during queue processing are non-fatal; // the client has likely disconnected. - console.warn('[warn] processQueue error:', err?.message ?? err); + // Only log err.message to avoid leaking sensitive fields (e.g. passwords) from command objects. + console.warn('[warn] processQueue error:', err?.message ?? String(err)); if (process.env.AGENT_BROWSER_DEBUG === '1') { - console.error('[DEBUG] processQueue error (full):', err); + console.error( + '[DEBUG] processQueue error stack:', + err?.stack ?? err?.message ?? String(err) + ); } }); }); diff --git a/src/domain-filter.test.ts b/src/domain-filter.test.ts new file mode 100644 index 0000000..36d327f --- /dev/null +++ b/src/domain-filter.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { isDomainAllowed, parseDomainList, buildWebSocketFilterScript } from './domain-filter.js'; + +describe('domain-filter', () => { + describe('isDomainAllowed', () => { + it('should match exact domains', () => { + expect(isDomainAllowed('example.com', ['example.com'])).toBe(true); + expect(isDomainAllowed('github.com', ['github.com'])).toBe(true); + }); + + it('should reject non-matching domains', () => { + expect(isDomainAllowed('evil.com', ['example.com'])).toBe(false); + expect(isDomainAllowed('notexample.com', ['example.com'])).toBe(false); + }); + + it('should match wildcard patterns', () => { + expect(isDomainAllowed('sub.example.com', ['*.example.com'])).toBe(true); + expect(isDomainAllowed('deep.sub.example.com', ['*.example.com'])).toBe(true); + }); + + it('should match bare domain against wildcard pattern', () => { + expect(isDomainAllowed('example.com', ['*.example.com'])).toBe(true); + }); + + it('should reject non-matching wildcard patterns', () => { + expect(isDomainAllowed('example.org', ['*.example.com'])).toBe(false); + expect(isDomainAllowed('evil.com', ['*.example.com'])).toBe(false); + }); + + it('should return false for empty allowlist', () => { + expect(isDomainAllowed('example.com', [])).toBe(false); + }); + + it('should match against multiple patterns', () => { + const patterns = ['example.com', '*.github.com', 'vercel.app']; + expect(isDomainAllowed('example.com', patterns)).toBe(true); + expect(isDomainAllowed('api.github.com', patterns)).toBe(true); + expect(isDomainAllowed('vercel.app', patterns)).toBe(true); + expect(isDomainAllowed('evil.com', patterns)).toBe(false); + }); + + it('should not partially match domain suffixes without wildcard', () => { + expect(isDomainAllowed('sub.example.com', ['example.com'])).toBe(false); + }); + }); + + describe('parseDomainList', () => { + it('should split comma-separated domains', () => { + expect(parseDomainList('a.com,b.com')).toEqual(['a.com', 'b.com']); + }); + + it('should trim whitespace', () => { + expect(parseDomainList(' a.com , b.com ')).toEqual(['a.com', 'b.com']); + }); + + it('should lowercase domains', () => { + expect(parseDomainList('Example.COM,GitHub.Com')).toEqual(['example.com', 'github.com']); + }); + + it('should filter empty entries', () => { + expect(parseDomainList('a.com,,b.com,')).toEqual(['a.com', 'b.com']); + }); + + it('should handle empty string', () => { + expect(parseDomainList('')).toEqual([]); + }); + + it('should preserve wildcard prefixes', () => { + expect(parseDomainList('*.example.com')).toEqual(['*.example.com']); + }); + }); + + describe('buildWebSocketFilterScript', () => { + it('should produce a valid JavaScript IIFE', () => { + const script = buildWebSocketFilterScript(['example.com', '*.github.com']); + expect(script).toContain('_allowedDomains'); + expect(script).toContain('"example.com"'); + expect(script).toContain('"*.github.com"'); + }); + + it('should embed the domain list as JSON', () => { + const script = buildWebSocketFilterScript(['a.com']); + expect(script).toContain('["a.com"]'); + }); + + it('should include WebSocket, EventSource, and sendBeacon patches', () => { + const script = buildWebSocketFilterScript(['a.com']); + expect(script).toContain('WebSocket'); + expect(script).toContain('EventSource'); + expect(script).toContain('SecurityError'); + expect(script).toContain('sendBeacon'); + }); + + it('should handle empty allowlist', () => { + const script = buildWebSocketFilterScript([]); + expect(script).toContain('[]'); + }); + + it('should include domain matching logic consistent with isDomainAllowed', () => { + const script = buildWebSocketFilterScript(['*.example.com']); + expect(script).toContain('_isDomainAllowed'); + expect(script).toContain('slice(1)'); + expect(script).toContain('slice(2)'); + }); + }); +}); diff --git a/src/domain-filter.ts b/src/domain-filter.ts new file mode 100644 index 0000000..19dbff8 --- /dev/null +++ b/src/domain-filter.ts @@ -0,0 +1,156 @@ +import type { BrowserContext, Route } from 'playwright-core'; + +/** + * Checks whether a hostname matches one of the allowed domain patterns. + * Patterns support exact match ("example.com") and wildcard prefix ("*.example.com"). + */ +export function isDomainAllowed(hostname: string, allowedDomains: string[]): boolean { + for (const pattern of allowedDomains) { + if (pattern.startsWith('*.')) { + const suffix = pattern.slice(1); // ".example.com" + if (hostname === pattern.slice(2) || hostname.endsWith(suffix)) { + return true; + } + } else if (hostname === pattern) { + return true; + } + } + return false; +} + +export function parseDomainList(raw: string): string[] { + return raw + .split(',') + .map((d) => d.trim().toLowerCase()) + .filter((d) => d.length > 0); +} + +/** + * Build the init script source that monkey-patches WebSocket, EventSource, + * and navigator.sendBeacon to block connections to non-allowed domains. + * Exported for testing. + */ +export function buildWebSocketFilterScript(allowedDomains: string[]): string { + const serialized = JSON.stringify(allowedDomains); + return `(function() { + var _allowedDomains = ${serialized}; + function _isDomainAllowed(hostname) { + hostname = hostname.toLowerCase(); + for (var i = 0; i < _allowedDomains.length; i++) { + var pattern = _allowedDomains[i]; + if (pattern.indexOf('*.') === 0) { + var suffix = pattern.slice(1); + if (hostname === pattern.slice(2) || hostname.slice(-suffix.length) === suffix) { + return true; + } + } else if (hostname === pattern) { + return true; + } + } + return false; + } + function _checkUrl(url) { + try { + var parsed = new URL(url); + return _isDomainAllowed(parsed.hostname); + } catch(e) { + return false; + } + } + if (typeof WebSocket !== 'undefined') { + var _OrigWS = WebSocket; + WebSocket = function(url, protocols) { + if (!_checkUrl(url)) { + throw new DOMException( + 'WebSocket connection to ' + url + ' blocked by domain allowlist', + 'SecurityError' + ); + } + if (protocols !== undefined) { + return new _OrigWS(url, protocols); + } + return new _OrigWS(url); + }; + WebSocket.prototype = _OrigWS.prototype; + WebSocket.CONNECTING = _OrigWS.CONNECTING; + WebSocket.OPEN = _OrigWS.OPEN; + WebSocket.CLOSING = _OrigWS.CLOSING; + WebSocket.CLOSED = _OrigWS.CLOSED; + } + if (typeof EventSource !== 'undefined') { + var _OrigES = EventSource; + EventSource = function(url, opts) { + if (!_checkUrl(url)) { + throw new DOMException( + 'EventSource connection to ' + url + ' blocked by domain allowlist', + 'SecurityError' + ); + } + return new _OrigES(url, opts); + }; + EventSource.prototype = _OrigES.prototype; + EventSource.CONNECTING = _OrigES.CONNECTING; + EventSource.OPEN = _OrigES.OPEN; + EventSource.CLOSED = _OrigES.CLOSED; + } + if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') { + var _origSendBeacon = navigator.sendBeacon.bind(navigator); + navigator.sendBeacon = function(url, data) { + if (!_checkUrl(url)) { + return false; + } + return _origSendBeacon(url, data); + }; + } +})();`; +} + +/** + * Installs a context-level route that enforces the domain allowlist. + * Both document navigations and sub-resource requests (scripts, images, fetch, etc.) + * to non-allowed domains are blocked, preventing data exfiltration. + * Non-http(s) schemes (data:, blob:, etc.) are allowed for sub-resources + * but blocked for document navigations. + * + * Also installs an init script that patches WebSocket, EventSource, and + * navigator.sendBeacon to block connections to non-allowed domains. This is + * a best-effort defense: if eval is permitted by action policy, page scripts + * could theoretically restore the originals. Denying the eval action + * category closes that loophole. + */ +export async function installDomainFilter( + context: BrowserContext, + allowedDomains: string[] +): Promise { + if (allowedDomains.length === 0) return; + + await context.addInitScript(buildWebSocketFilterScript(allowedDomains)); + + await context.route('**/*', async (route: Route) => { + const request = route.request(); + const urlStr = request.url(); + + if (!urlStr.startsWith('http://') && !urlStr.startsWith('https://')) { + if (request.resourceType() === 'document') { + await route.abort('blockedbyclient'); + } else { + await route.continue(); + } + return; + } + + let hostname: string; + try { + hostname = new URL(urlStr).hostname.toLowerCase(); + } catch { + await route.abort('blockedbyclient'); + return; + } + + if (isDomainAllowed(hostname, allowedDomains)) { + await route.continue(); + } else { + await route.abort('blockedbyclient'); + } + }); +} diff --git a/src/encryption.ts b/src/encryption.ts index 577ee15..3b366a0 100644 --- a/src/encryption.ts +++ b/src/encryption.ts @@ -3,6 +3,10 @@ */ import * as crypto from 'crypto'; +import { execSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import os from 'node:os'; // ============================================ // Constants @@ -10,6 +14,7 @@ import * as crypto from 'crypto'; export const ENCRYPTION_ALGORITHM = 'aes-256-gcm'; export const ENCRYPTION_KEY_ENV = 'AGENT_BROWSER_ENCRYPTION_KEY'; export const IV_LENGTH = 12; // 96 bits for GCM +const KEY_FILE_NAME = '.encryption-key'; /** * Encrypted payload structure. @@ -22,27 +27,114 @@ export interface EncryptedPayload { data: string; // Base64 encoded ciphertext } +export function getKeyFilePath(): string { + return join(os.homedir(), '.agent-browser', KEY_FILE_NAME); +} + /** - * Get encryption key from environment variable. + * Restrict file permissions to the current user only. + * On Unix, the caller should use `mode: 0o600` when writing. This function + * handles Windows where Node's mode parameter is ignored. + */ +export function restrictFilePermissions(filePath: string): void { + if (os.platform() !== 'win32') return; + try { + execSync(`icacls "${filePath}" /inheritance:r /grant:r "%USERNAME%:F"`, { + stdio: 'ignore', + windowsHide: true, + }); + } catch { + // Best-effort; may fail in some environments (containers, restricted shells) + } +} + +/** + * Restrict directory permissions to the current user only. + * On Unix, the caller should use `mode: 0o700` when creating. This function + * handles Windows where Node's mode parameter is ignored. + */ +export function restrictDirPermissions(dirPath: string): void { + if (os.platform() !== 'win32') return; + try { + execSync(`icacls "${dirPath}" /inheritance:r /grant:r "%USERNAME%:(OI)(CI)F"`, { + stdio: 'ignore', + windowsHide: true, + }); + } catch { + // Best-effort + } +} + +function parseKeyHex(keyHex: string): Buffer | null { + if (!/^[a-fA-F0-9]{64}$/.test(keyHex.trim())) return null; + return Buffer.from(keyHex.trim(), 'hex'); +} + +/** + * Get encryption key from environment variable or key file. * The key should be a 32-byte (256-bit) hex-encoded string (64 characters). * Generate with: openssl rand -hex 32 * - * @returns Buffer containing the key, or null if not set/invalid + * Checks (in order): + * 1. AGENT_BROWSER_ENCRYPTION_KEY env var + * 2. ~/.agent-browser/.encryption-key file + * + * @returns Buffer containing the key, or null if not available */ export function getEncryptionKey(): Buffer | null { const keyHex = process.env[ENCRYPTION_KEY_ENV]; - if (!keyHex) return null; - - // Key should be 64 hex chars = 32 bytes = 256 bits - if (!/^[a-fA-F0-9]{64}$/.test(keyHex)) { - console.warn( - `Warning: ${ENCRYPTION_KEY_ENV} should be a 64-character hex string (256 bits). ` + - `Generate one with: openssl rand -hex 32` - ); - return null; + if (keyHex) { + const key = parseKeyHex(keyHex); + if (!key) { + console.warn( + `Warning: ${ENCRYPTION_KEY_ENV} should be a 64-character hex string (256 bits). ` + + `Generate one with: openssl rand -hex 32` + ); + return null; + } + return key; } - return Buffer.from(keyHex, 'hex'); + const keyFilePath = getKeyFilePath(); + if (existsSync(keyFilePath)) { + try { + const fileHex = readFileSync(keyFilePath, 'utf-8'); + return parseKeyHex(fileHex); + } catch { + return null; + } + } + + return null; +} + +/** + * Ensure an encryption key is available, auto-generating one if needed. + * On first call without an existing key, generates a random 256-bit key + * and writes it to ~/.agent-browser/.encryption-key (mode 0600). + */ +export function ensureEncryptionKey(): Buffer { + const existing = getEncryptionKey(); + if (existing) return existing; + + const key = crypto.randomBytes(32); + const keyHex = key.toString('hex'); + + const dir = join(os.homedir(), '.agent-browser'); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + restrictDirPermissions(dir); + } + + const keyFilePath = getKeyFilePath(); + writeFileSync(keyFilePath, keyHex + '\n', { mode: 0o600 }); + restrictFilePermissions(keyFilePath); + + console.error( + `[agent-browser] Auto-generated encryption key at ${keyFilePath} -- back up this file or set ${ENCRYPTION_KEY_ENV}` + ); + + return key; } /** diff --git a/src/protocol.ts b/src/protocol.ts index fbd5643..e93da1d 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -53,6 +53,9 @@ const launchSchema = baseCommandSchema.extend({ downloadPath: z.string().optional(), profile: z.string().optional(), storageState: z.string().optional(), + allowedDomains: z.array(z.string()).optional(), + actionPolicy: z.string().optional(), + confirmActions: z.array(z.string()).optional(), }); const navigateSchema = baseCommandSchema.extend({ @@ -873,6 +876,53 @@ const windowNewSchema = baseCommandSchema.extend({ .optional(), }); +const authProfileName = z + .string() + .min(1) + .regex(/^[a-zA-Z0-9_-]+$/, { + message: 'Profile name must contain only alphanumeric characters, hyphens, and underscores', + }); + +const authSaveSchema = baseCommandSchema.extend({ + action: z.literal('auth_save'), + name: authProfileName, + url: z.string().min(1), + username: z.string().min(1), + password: z.string().min(1), + usernameSelector: z.string().optional(), + passwordSelector: z.string().optional(), + submitSelector: z.string().optional(), +}); + +const authLoginSchema = baseCommandSchema.extend({ + action: z.literal('auth_login'), + name: authProfileName, +}); + +const authListSchema = baseCommandSchema.extend({ + action: z.literal('auth_list'), +}); + +const authDeleteSchema = baseCommandSchema.extend({ + action: z.literal('auth_delete'), + name: authProfileName, +}); + +const authShowSchema = baseCommandSchema.extend({ + action: z.literal('auth_show'), + name: authProfileName, +}); + +const confirmSchema = baseCommandSchema.extend({ + action: z.literal('confirm'), + confirmationId: z.string().min(1), +}); + +const denySchema = baseCommandSchema.extend({ + action: z.literal('deny'), + confirmationId: z.string().min(1), +}); + // Union schema for all commands const commandSchema = z.discriminatedUnion('action', [ launchSchema, @@ -1010,6 +1060,13 @@ const commandSchema = z.discriminatedUnion('action', [ diffSnapshotSchema, diffScreenshotSchema, diffUrlSchema, + confirmSchema, + denySchema, + authSaveSchema, + authLoginSchema, + authListSchema, + authDeleteSchema, + authShowSchema, ]); // Parse result type diff --git a/src/types.ts b/src/types.ts index ddffd40..df18acf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,6 +33,9 @@ export interface LaunchCommand extends BaseCommand { allowFileAccess?: boolean; // Enable file:// URL access and cross-origin file requests colorScheme?: 'light' | 'dark' | 'no-preference'; // Persistent color scheme override downloadPath?: string; // Directory for browser downloads (Playwright's downloadsPath) + allowedDomains?: string[]; + actionPolicy?: string; + confirmActions?: string[]; // Auto-load state file for session persistence autoStateFilePath?: string; } @@ -1022,7 +1025,54 @@ export type Command = | DeviceListCommand | DiffSnapshotCommand | DiffScreenshotCommand - | DiffUrlCommand; + | DiffUrlCommand + | AuthSaveCommand + | AuthLoginCommand + | AuthListCommand + | AuthDeleteCommand + | AuthShowCommand + | ConfirmCommand + | DenyCommand; + +export interface AuthSaveCommand extends BaseCommand { + action: 'auth_save'; + name: string; + url: string; + username: string; + password: string; + usernameSelector?: string; + passwordSelector?: string; + submitSelector?: string; +} + +export interface AuthLoginCommand extends BaseCommand { + action: 'auth_login'; + name: string; +} + +export interface AuthListCommand extends BaseCommand { + action: 'auth_list'; +} + +export interface AuthDeleteCommand extends BaseCommand { + action: 'auth_delete'; + name: string; +} + +export interface AuthShowCommand extends BaseCommand { + action: 'auth_show'; + name: string; +} + +export interface ConfirmCommand extends BaseCommand { + action: 'confirm'; + confirmationId: string; +} + +export interface DenyCommand extends BaseCommand { + action: 'deny'; + confirmationId: string; +} // Diff commands export interface DiffSnapshotCommand extends BaseCommand { @@ -1091,14 +1141,39 @@ export interface ScreenshotData { export interface SnapshotData { snapshot: string; + refs?: Record; + origin?: string; } export interface EvaluateData { result: unknown; + origin?: string; } export interface ContentData { html: string; + origin?: string; +} + +export interface TextData { + text: string | null; + origin?: string; +} + +export interface AttributeData { + attribute: string; + value: string | null; + origin?: string; +} + +export interface ValueData { + value: string; + origin?: string; +} + +export interface ConsoleData { + messages: Array<{ type: string; text: string }>; + origin?: string; } export interface TabInfo {