add security hardening features (#543)

* add security hardening features

- Add authentication vault (`auth save/login/list/show/delete`) so credentials are stored locally and never exposed to the LLM (fixes Snyk W007)
- Add `--content-boundaries` flag to wrap page-sourced output in structural markers, helping LLMs distinguish tool output from untrusted page content (fixes Snyk W011)
- Add `--allowed-domains` flag to restrict browser navigation to trusted domains
- Add `--action-policy` for static allow/deny gating of action categories, with opt-in `--confirm-actions`/`--confirm-interactive` for orchestrator or human-in-the-loop confirmation
- Add `--max-output` flag to truncate large page outputs, preventing context flooding
- New docs page at /security, updated README, SKILL.md, CLI help text, and templates

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* docs
This commit is contained in:
Chris Tate
2026-02-25 15:33:20 -06:00
committed by GitHub
parent c0e2b80f8c
commit bc1e917e87
28 changed files with 3444 additions and 476 deletions
+28
View File
@@ -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 <scheme>` | Color scheme: `dark`, `light`, `no-preference` (or `AGENT_BROWSER_COLOR_SCHEME` env) |
| `--download-path <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 <chars>` | Truncate page output to N characters (or `AGENT_BROWSER_MAX_OUTPUT` env) |
| `--allowed-domains <list>` | Comma-separated allowed domain patterns (or `AGENT_BROWSER_ALLOWED_DOMAINS` env) |
| `--action-policy <path>` | Path to action policy JSON file (or `AGENT_BROWSER_ACTION_POLICY` env) |
| `--confirm-actions <list>` | 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 <path>` | Use a custom config file (or `AGENT_BROWSER_CONFIG` env) |
| `--debug` | Debug output |
+1
View File
@@ -8,6 +8,7 @@ version = "0.14.0"
dependencies = [
"base64",
"dirs",
"getrandom",
"libc",
"serde",
"serde_json",
+1
View File
@@ -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"
+133
View File
@@ -92,6 +92,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
match cmd {
// === Navigation ===
// Maps to "navigate" action in protocol; reflected in ACTION_CATEGORIES in action-policy.ts
"open" | "goto" | "navigate" => {
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<Value, ParseError
// === Close ===
"close" | "quit" | "exit" => 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 <name> --url <url> --username <user> --password <pass>",
})?;
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 <name> --url <url> --username <user> --password <pass>",
});
}
}
}
j += 1;
}
let url_val = url.ok_or_else(|| ParseError::MissingArguments {
context: "auth save".to_string(),
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass> [--password-stdin]",
})?;
let user_val = username.ok_or_else(|| ParseError::MissingArguments {
context: "auth save".to_string(),
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass> [--password-stdin]",
})?;
if !password_stdin && password.is_none() {
return Err(ParseError::MissingArguments {
context: "auth save".to_string(),
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass> [--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 <name>",
})?;
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 <name>",
})?;
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 <name>",
})?;
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 <confirmation-id>",
})?;
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 <confirmation-id>",
})?;
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,
}
}
+90 -142
View File
@@ -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<DaemonResult, String> {
// 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;
+85
View File
@@ -35,6 +35,12 @@ pub struct Config {
pub annotate: Option<bool>,
pub color_scheme: Option<String>,
pub download_path: Option<String>,
pub content_boundaries: Option<bool>,
pub max_output: Option<usize>,
pub allowed_domains: Option<Vec<String>>,
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: Option<bool>,
}
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 <path> from args before full flag parsing.
/// Returns `Some(Some(path))` if --config <path> 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<Option<String>> {
const FLAGS_WITH_VALUE: &[&str] = &[
"--session",
@@ -135,6 +152,10 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
"--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<String>,
pub download_path: Option<String>,
pub content_boundaries: bool,
pub max_output: Option<usize>,
pub allowed_domains: Option<Vec<String>>,
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
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::<usize>() {
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<String> {
"--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<String> {
"--session-name",
"--color-scheme",
"--download-path",
"--max-output",
"--allowed-domains",
"--action-policy",
"--confirm-actions",
"--config",
];
+208 -22
View File
@@ -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::<connection::Response>(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);
}
+262 -14
View File
@@ -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<String> = 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<usize>,
}
fn truncate_if_needed(content: &str, max: Option<usize>) -> 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 <subcommand> [args]
Subcommands:
save <name> Save credentials for a login profile
login <name> Login using saved credentials
list List saved profiles (names and URLs only)
show <name> Show profile metadata (no passwords)
delete <name> Delete a saved profile
Save Options:
--url <url> Login page URL (required)
--username <user> Username (required)
--password <pass> Password (required unless --password-stdin)
--password-stdin Read password from stdin (recommended)
--username-selector <s> Custom CSS selector for username field
--password-selector <s> Custom CSS selector for password field
--submit-selector <s> Custom CSS selector for submit button
Global Options:
--json Output as JSON
--session <name> 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 <confirmation-id>
agent-browser deny <confirmation-id>
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 <sel> Highlight element
Auth Vault:
auth save <name> [opts] Save auth profile (--url, --username, --password/--password-stdin)
auth login <name> Login using saved credentials
auth list List saved auth profiles
auth show <name> Show auth profile metadata
auth delete <name> Delete auth profile
Confirmation:
confirm <id> Approve a pending action
deny <id> Deny a pending action
Sessions:
session Show current session name
session list List active sessions
@@ -2112,6 +2348,12 @@ Options:
--color-scheme <scheme> Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME)
--download-path <path> Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH)
--session-name <name> Auto-save/restore session state (cookies, localStorage)
--content-boundaries Wrap page output in boundary markers (or AGENT_BROWSER_CONTENT_BOUNDARIES)
--max-output <chars> Truncate page output to N chars (or AGENT_BROWSER_MAX_OUTPUT)
--allowed-domains <list> Restrict navigation domains (or AGENT_BROWSER_ALLOWED_DOMAINS)
--action-policy <path> Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY)
--confirm-actions <list> 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 <path> 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
+243
View File
@@ -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"]
}
```
<table>
<thead>
<tr><th>Category</th><th>Actions</th></tr>
</thead>
<tbody>
<tr><td><code>navigate</code></td><td>open, back, forward, reload, tab new</td></tr>
<tr><td><code>click</code></td><td>click, dblclick, tap</td></tr>
<tr><td><code>fill</code></td><td>fill, type, keyboard type/inserttext, select, check, uncheck</td></tr>
<tr><td><code>eval</code></td><td>eval, evalhandle, addscript, addinitscript, addstyle, expose, setcontent</td></tr>
<tr><td><code>download</code></td><td>download, waitfordownload</td></tr>
<tr><td><code>upload</code></td><td>upload</td></tr>
<tr><td><code>snapshot</code></td><td>snapshot, screenshot, pdf, diff</td></tr>
<tr><td><code>scroll</code></td><td>scroll, scrollintoview</td></tr>
<tr><td><code>wait</code></td><td>wait, waitforurl, waitforloadstate, waitforfunction</td></tr>
<tr><td><code>get</code></td><td>get text/html/url/title, count, isvisible, getbyrole, getbytext, getbylabel, etc.</td></tr>
<tr><td><code>interact</code></td><td>hover, focus, drag, press, keydown, keyup, mousemove, dispatch</td></tr>
<tr><td><code>network</code></td><td>network route/unroute, requests</td></tr>
<tr><td><code>state</code></td><td>state save/load, cookies set, storage set</td></tr>
</tbody>
</table>
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
<table>
<thead>
<tr><th>Variable</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>AGENT_BROWSER_CONTENT_BOUNDARIES</code></td><td>Wrap page output in boundary markers</td></tr>
<tr><td><code>AGENT_BROWSER_MAX_OUTPUT</code></td><td>Max characters for page output</td></tr>
<tr><td><code>AGENT_BROWSER_ALLOWED_DOMAINS</code></td><td>Comma-separated allowed domain patterns</td></tr>
<tr><td><code>AGENT_BROWSER_ACTION_POLICY</code></td><td>Path to action policy JSON file</td></tr>
<tr><td><code>AGENT_BROWSER_CONFIRM_ACTIONS</code></td><td>Comma-separated action categories requiring confirmation</td></tr>
<tr><td><code>AGENT_BROWSER_CONFIRM_INTERACTIVE</code></td><td>Enable interactive confirmation prompts</td></tr>
<tr><td><code>AGENT_BROWSER_ENCRYPTION_KEY</code></td><td>64-char hex key for AES-256-GCM encryption (auth vault + sessions)</td></tr>
</tbody>
</table>
## Recommended Configuration
For production AI agent deployments:
```json
{
"contentBoundaries": true,
"maxOutput": 50000,
"allowedDomains": ["your-app.com", "*.your-app.com"],
"actionPolicy": "./policy.json"
}
```
+1
View File
@@ -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" },
],
},
{
+1
View File
@@ -12,6 +12,7 @@ export const PAGE_TITLES: Record<string, string> = {
streaming: "Streaming",
profiler: "Profiler",
ios: "iOS Simulator",
security: "Security",
changelog: "Changelog",
};
+66
View File
@@ -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 "<UDID>"` 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=<hex> origin=https://example.com ---
# [accessibility tree]
# --- END_AGENT_BROWSER_PAGE_CONTENT nonce=<hex> ---
```
### 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.
@@ -3,6 +3,11 @@
# Purpose: Login once, save state, reuse for subsequent runs
# Usage: ./authenticated-session.sh <login-url> [state-file]
#
# RECOMMENDED: Use the auth vault instead of this template:
# echo "<pass>" | agent-browser auth save myapp --url <login-url> --username <user> --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
+213
View File
@@ -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');
});
});
});
+297
View File
@@ -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<string, string> = {
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<string>
): 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, unknown>): 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}`;
}
}
+513 -280
View File
@@ -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<string>();
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<Response> {
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<string, unknown>
);
const { confirmationId } = requestConfirmation(
command.action,
category,
description,
command as unknown as Record<string, unknown>
);
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<Response> {
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<Response<NavigateData>> {
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<Response> {
@@ -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<Response> {
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<Response> {
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<Response> {
@@ -1989,16 +2085,17 @@ async function handleInnerHtml(
): Promise<Response> {
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<Response> {
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<Response> {
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<Response> {
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 });
}
+120
View File
@@ -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 <json-command>
* 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, unknown>): 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 <json-command>\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();
+278
View File
@@ -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<typeof import('os')>();
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<string, unknown>).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');
});
});
});
+189
View File
@@ -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);
}
}
+81 -2
View File
@@ -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<string, (route: Route) => Promise<void>> = 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<void> {
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<void> {
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)
+67
View File
@@ -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();
});
});
});
+53
View File
@@ -0,0 +1,53 @@
import { randomBytes } from 'node:crypto';
interface PendingConfirmation {
id: string;
action: string;
category: string;
description: string;
command: Record<string, unknown>;
timer: ReturnType<typeof setTimeout>;
}
const AUTO_DENY_TIMEOUT_MS = 60_000;
const pending = new Map<string, PendingConfirmation>();
function generateId(): string {
return `c_${randomBytes(8).toString('hex')}`;
}
export function requestConfirmation(
action: string,
category: string,
description: string,
command: Record<string, unknown>
): { 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<string, unknown>; 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 };
}
+10 -3
View File
@@ -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)
);
}
});
});
+106
View File
@@ -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)');
});
});
});
+156
View File
@@ -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<void> {
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');
}
});
}
+104 -12
View File
@@ -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;
}
/**
+57
View File
@@ -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
+76 -1
View File
@@ -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<string, { role: string; name?: string }>;
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 {