diff --git a/cli/src/output.rs b/cli/src/output.rs index 0f3ae46..93beffb 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -1,9 +1,83 @@ +use std::sync::OnceLock; + use crate::color; use crate::connection::Response; -pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) { - if json_mode { - println!("{}", serde_json::to_string(resp).unwrap_or_default()); +static BOUNDARY_NONCE: OnceLock = OnceLock::new(); + +/// Per-process nonce for content boundary markers. Uses a CSPRNG (getrandom) so +/// that untrusted page content cannot predict or spoof the boundary delimiter. +/// Process ID or timestamps would be insufficient since pages can read those. +fn get_boundary_nonce() -> &'static str { + BOUNDARY_NONCE.get_or_init(|| { + let mut buf = [0u8; 16]; + getrandom::getrandom(&mut buf).expect("failed to generate random nonce"); + buf.iter().map(|b| format!("{:02x}", b)).collect() + }) +} + +#[derive(Default)] +pub struct OutputOptions { + pub json: bool, + pub content_boundaries: bool, + pub max_output: Option, +} + +fn truncate_if_needed(content: &str, max: Option) -> String { + let Some(limit) = max else { + return content.to_string(); + }; + // Fast path: byte length is a lower bound on char count, so if the + // byte length is within the limit the char count must be too. + if content.len() <= limit { + return content.to_string(); + } + // Find the byte offset of the limit-th character. + match content.char_indices().nth(limit).map(|(i, _)| i) { + Some(byte_offset) => { + let total_chars = content.chars().count(); + format!( + "{}\n[truncated: showing {} of {} chars. Use --max-output to adjust]", + &content[..byte_offset], limit, total_chars + ) + } + // Content has fewer than `limit` chars despite more bytes + None => content.to_string(), + } +} + +fn print_with_boundaries(content: &str, origin: Option<&str>, opts: &OutputOptions) { + let content = truncate_if_needed(content, opts.max_output); + if opts.content_boundaries { + let origin_str = origin.unwrap_or("unknown"); + let nonce = get_boundary_nonce(); + println!("--- AGENT_BROWSER_PAGE_CONTENT nonce={} origin={} ---", nonce, origin_str); + println!("{}", content); + println!("--- END_AGENT_BROWSER_PAGE_CONTENT nonce={} ---", nonce); + } else { + println!("{}", content); + } +} + +pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &OutputOptions) { + if opts.json { + if opts.content_boundaries { + let mut json_val = serde_json::to_value(resp).unwrap_or_default(); + if let Some(obj) = json_val.as_object_mut() { + let nonce = get_boundary_nonce(); + let origin = obj.get("data") + .and_then(|d| d.get("origin")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + obj.insert("_boundary".to_string(), serde_json::json!({ + "nonce": nonce, + "origin": origin, + })); + } + println!("{}", serde_json::to_string(&json_val).unwrap_or_default()); + } else { + println!("{}", serde_json::to_string(resp).unwrap_or_default()); + } return; } @@ -82,9 +156,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 @@ -94,12 +169,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 @@ -127,10 +202,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 @@ -217,10 +290,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; } @@ -593,6 +679,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()); } @@ -1600,6 +1767,64 @@ Examples: "## } + // === Auth === + "auth" => { + r##" +agent-browser auth - Manage authentication profiles + +Usage: agent-browser auth [args] + +Subcommands: + save Save credentials for a login profile + login Login using saved credentials + list List saved profiles (names and URLs only) + show Show profile metadata (no passwords) + delete Delete a saved profile + +Save Options: + --url Login page URL (required) + --username Username (required) + --password Password (required unless --password-stdin) + --password-stdin Read password from stdin (recommended) + --username-selector Custom CSS selector for username field + --password-selector Custom CSS selector for password field + --submit-selector Custom CSS selector for submit button + +Global Options: + --json Output as JSON + --session Use specific session + +Examples: + echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin + agent-browser auth save github --url https://github.com/login --username user --password pass + agent-browser auth login github + agent-browser auth list + agent-browser auth show github + agent-browser auth delete github +"## + } + + // === Confirm/Deny === + "confirm" | "deny" => { + r##" +agent-browser confirm/deny - Approve or deny pending actions + +Usage: + agent-browser confirm + agent-browser deny + +When --confirm-actions is set, certain action categories return a +confirmation_required response with a confirmation ID. Use confirm/deny +to approve or reject the action. + +Pending confirmations auto-deny after 60 seconds. + +Examples: + agent-browser confirm c_8f3a1234 + agent-browser deny c_8f3a1234 +"## + } + // === Dialog === "dialog" => { r##" @@ -2125,6 +2350,17 @@ Debug: errors [--clear] View page errors highlight Highlight element +Auth Vault: + auth save [opts] Save auth profile (--url, --username, --password/--password-stdin) + auth login Login using saved credentials + auth list List saved auth profiles + auth show Show auth profile metadata + auth delete Delete auth profile + +Confirmation: + confirm Approve a pending action + deny Deny a pending action + Sessions: session Show current session name session list List active sessions @@ -2167,6 +2403,12 @@ Options: --download-path Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH) --risk-mode Verify/captcha handling: off, warn, block (or AGENT_BROWSER_RISK_MODE) --session-name Auto-save/restore session state (cookies, localStorage) + --content-boundaries Wrap page output in boundary markers (or AGENT_BROWSER_CONTENT_BOUNDARIES) + --max-output Truncate page output to N chars (or AGENT_BROWSER_MAX_OUTPUT) + --allowed-domains Restrict navigation domains (or AGENT_BROWSER_ALLOWED_DOMAINS) + --action-policy Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY) + --confirm-actions Categories requiring confirmation (or AGENT_BROWSER_CONFIRM_ACTIONS) + --confirm-interactive Interactive confirmation prompts; auto-denies if stdin is not a TTY (or AGENT_BROWSER_CONFIRM_INTERACTIVE) --config Use a custom config file (or AGENT_BROWSER_CONFIG env) --debug Debug output --version, -V Show version (fork builds include upstream/fork info) @@ -2225,6 +2467,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-stealth diff --git a/docs/src/app/changelog/page.mdx b/docs/src/app/changelog/page.mdx index d5d8e54..7000a48 100644 --- a/docs/src/app/changelog/page.mdx +++ b/docs/src/app/changelog/page.mdx @@ -4,6 +4,123 @@ export const metadata = pageMetadata("changelog") # Changelog +## v0.15.0 + +

February 2026

+ +### New Features + +- **Authentication vault** -- Store credentials locally (always AES-256-GCM encrypted) and reference them by name. The LLM never sees passwords. Commands: `auth save`, `auth login`, `auth list`, `auth show`, `auth delete`. Passwords can be piped via stdin (`--password-stdin`) to avoid shell history exposure. +- **Content boundary markers** -- `--content-boundaries` wraps page-sourced output in structural delimiters with a per-process CSPRNG nonce, so LLMs can distinguish trusted tool output from untrusted page content. In `--json` mode, a `_boundary` object is injected with `nonce` and `origin` fields. +- **Domain allowlist** -- `--allowed-domains` restricts navigation, sub-resource requests, WebSocket connections, and EventSource streams to trusted domains. Supports exact match and wildcard prefix patterns (e.g., `*.example.com`). +- **Action policy** -- `--action-policy` gates actions using a static JSON policy file with `allow`/`deny` lists across 13 action categories. Auth vault operations bypass policy enforcement. +- **Action confirmation** -- `--confirm-actions` requires explicit approval for sensitive action categories. New `confirm` and `deny` commands for orchestrator use. `--confirm-interactive` enables human-in-the-loop terminal prompts (auto-denies if stdin is not a TTY). Pending confirmations auto-deny after 60 seconds. +- **Output length limits** -- `--max-output` truncates large page outputs to prevent LLM context flooding. +- **`--download-path` option** -- Set a default download directory via flag, `AGENT_BROWSER_DOWNLOAD_PATH` env var, or `downloadPath` config key. Without it, downloads go to a temporary directory deleted when the browser closes. +- **`--selector` flag for scroll** -- Scroll within a specific container element instead of the page: `agent-browser scroll down 500 --selector "div.scroll-container"` + +```bash +# Auth vault +echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin +agent-browser auth login github + +# Security flags +agent-browser --content-boundaries --allowed-domains "example.com,*.example.com" --max-output 50000 open https://example.com + +# Download path +agent-browser --download-path ./downloads open https://example.com + +# Scroll within container +agent-browser scroll down 500 --selector "div.content" +``` + +### Environment Variables + +Six new environment variables for security configuration: `AGENT_BROWSER_CONTENT_BOUNDARIES`, `AGENT_BROWSER_MAX_OUTPUT`, `AGENT_BROWSER_ALLOWED_DOMAINS`, `AGENT_BROWSER_ACTION_POLICY`, `AGENT_BROWSER_CONFIRM_ACTIONS`, `AGENT_BROWSER_CONFIRM_INTERACTIVE`. + +--- + +## v0.14.0 + +

February 2026

+ +### New Features + +- **`keyboard` command** -- Type with real keystrokes, insert text, and press shortcuts at the currently focused element without needing a selector (`keyboard type`, `keyboard inserttext`). +- **`--color-scheme` flag** -- Persistent dark/light mode preference across browser sessions via flag or `AGENT_BROWSER_COLOR_SCHEME` env var. + +```bash +agent-browser keyboard type "Hello world" +agent-browser keyboard inserttext "pasted text" +agent-browser --color-scheme dark open https://example.com +``` + +### Bug Fixes + +- Fixed IPC EAGAIN errors (os error 35/11) with backpressure-aware socket writes, command serialization, and lowered default Playwright timeout to 25s (configurable via `AGENT_BROWSER_DEFAULT_TIMEOUT`). +- Fixed remote debugging (CDP) reconnection. +- Fixed state load failing when no browser is running. +- Fixed `--annotate` flag warning appearing when not explicitly passed via CLI. + +--- + +## v0.13.0 + +

February 2026

+ +### New Features + +- **Diff commands** -- Compare snapshots, screenshots, and URLs between page states. Run visual pixel diffs against baseline images, compare accessibility tree snapshots with customizable depth and selectors, and diff two URLs side-by-side with optional screenshot comparison. + +```bash +agent-browser diff snapshot +agent-browser diff screenshot --baseline before.png +agent-browser diff url https://staging.example.com https://prod.example.com +``` + +--- + +## v0.12.0 + +

February 2026

+ +### New Features + +- **Annotated screenshots** -- `--annotate` flag overlays numbered labels on interactive elements and prints a legend mapping each label to its element ref. Enables multimodal AI models to reason about visual layout while using the same `@eN` refs for subsequent interactions. Also settable via `AGENT_BROWSER_ANNOTATE` env var. + +```bash +agent-browser screenshot --annotate +``` + +--- + +## v0.11.1 + +

February 2026

+ +### Documentation + +- Added documentation for command chaining with `&&` across README, CLI help output, docs, and skill files. + +--- + +## v0.11.0 + +

February 2026

+ +### New Features + +- **Configuration file support** -- Automatic loading from user (`~/.agent-browser/config.json`) and project (`./agent-browser.json`) directories with priority-based merging. +- **Profiler commands** -- Chrome DevTools profiling with `profiler start` and `profiler stop`. +- **Browser extension loading** -- `--extension` flag to load browser extensions. +- **Storage state management** -- `state save` and `state load` commands for auth state persistence. +- **iOS device emulation** -- `--device` flag for device emulation. +- **Enhanced click** -- `--new-tab` option for click commands. +- **Enhanced find** -- Additional actions and filtering options. +- **CDP WebSocket URLs** -- `--cdp` now accepts WebSocket URLs in addition to ports. + +--- + ## v0.10.0

February 2026

diff --git a/docs/src/app/security/page.mdx b/docs/src/app/security/page.mdx new file mode 100644 index 0000000..b1813ca --- /dev/null +++ b/docs/src/app/security/page.mdx @@ -0,0 +1,243 @@ +import { pageMetadata } from "@/lib/page-metadata" +export const metadata = pageMetadata("security") + +# Security + +agent-browser includes security features to protect against credential exposure, prompt injection via untrusted page content, and unauthorized browser actions. + +All security features are opt-in. By default, agent-browser imposes no restrictions on navigation, actions, or output. Enable these features as needed for your deployment -- existing workflows are unaffected until you explicitly activate a feature. + +## Threat Model + +These features are designed to mitigate the following threats when an LLM-based agent drives a browser: + +- **Credential exposure** -- Passwords stored in the auth vault are never included in LLM context. The CLI handles vault operations locally; credentials do not pass through the daemon's IPC channel. +- **Prompt injection via page content** -- Malicious pages can embed text that looks like tool output or system instructions. Content boundary markers (`--content-boundaries`) let the orchestrator distinguish trusted tool output from untrusted page content. +- **Unauthorized navigation / data exfiltration** -- A compromised or manipulated agent could navigate to attacker-controlled domains to exfiltrate data. The domain allowlist (`--allowed-domains`) blocks navigations, sub-resource requests, WebSocket connections, EventSource streams, and `sendBeacon` calls to non-allowed domains. +- **Unauthorized destructive actions** -- Action policy (`--action-policy`) and confirmation gating (`--confirm-actions`) prevent the agent from performing dangerous operations (eval, downloads, uploads) without explicit approval. +- **Context flooding** -- Large page outputs can overwhelm an LLM's context window. Output truncation (`--max-output`) caps the size of page-sourced content. + +### Known limitations + +- **WebSocket/EventSource blocking is best-effort.** It works by overriding browser constructors via an init script. If the `eval` action category is allowed, page scripts could theoretically restore the original constructors. Deny `eval` via `--action-policy` for maximum protection. +- **Domain filter timing on remote connections.** When connecting to a pre-existing browser via CDP or a cloud provider, pages may have already loaded content before the domain filter is installed. agent-browser navigates disallowed pages to `about:blank` after the filter is active, but resources loaded before that point are not retroactively blocked. +- **Content boundaries are defense-in-depth.** They rely on the LLM and orchestrator respecting the structural markers. A sufficiently capable adversarial page could attempt to mimic the boundary format, though the per-process CSPRNG nonce makes this impractical to predict. +- **Confirmation timeout.** Pending confirmations auto-deny after 60 seconds. Orchestrators must respond within that window. +- **Non-TTY auto-deny.** When `--confirm-interactive` is set but stdin is not a terminal (e.g., piped input), actions are automatically denied to prevent accidental approval in non-interactive contexts. + +## Authentication Vault + +Store credentials locally and reference them by name. The LLM never sees passwords. + +```bash +# Save credentials (encrypted if AGENT_BROWSER_ENCRYPTION_KEY is set) +# Recommended: pipe password via stdin to avoid shell history / process listing exposure +echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin + +# Or pass directly (a warning will be shown) +agent-browser auth save github --url https://github.com/login --username user --password pass + +# Login using saved credentials +agent-browser auth login github + +# List saved profiles (names and URLs only, no secrets) +agent-browser auth list + +# Show profile metadata +agent-browser auth show github + +# Delete a profile +agent-browser auth delete github +``` + +Custom selectors can be specified if auto-detection fails: + +```bash +agent-browser auth save myapp \ + --url https://app.example.com/login \ + --username user --password pass \ + --username-selector "#email" \ + --password-selector "#password" \ + --submit-selector "button.login" +``` + +Profiles are stored in `~/.agent-browser/auth/` and always encrypted with AES-256-GCM. If `AGENT_BROWSER_ENCRYPTION_KEY` is not set, a key is auto-generated at `~/.agent-browser/.encryption-key` on first use. Back up this file or set the environment variable explicitly for portability. + +File permissions are enforced on both Unix (`chmod 600`/`700`) and Windows (`icacls` restricted to the current user) to prevent other users from reading encryption keys or auth profiles. + +## Content Boundary Markers + +When `--content-boundaries` is enabled, all page-sourced output is wrapped in structural markers so LLMs can distinguish tool output from untrusted page content: + +``` +--- AGENT_BROWSER_PAGE_CONTENT nonce=a1b2c3d4 origin=https://example.com --- +[snapshot / text / html / eval output here] +--- END_AGENT_BROWSER_PAGE_CONTENT nonce=a1b2c3d4 --- +``` + +The nonce is a random value generated per CLI process invocation, making it unpredictable to page content that might attempt to spoof the boundary. + +Enable via flag or environment variable: + +```bash +agent-browser --content-boundaries snapshot +# or +export AGENT_BROWSER_CONTENT_BOUNDARIES=1 +``` + +Affected output types: `snapshot`, `get text`, `get html`, `eval`, `console`. + +In `--json` mode, boundary metadata is injected into the JSON response as a `_boundary` object containing `nonce` and `origin` fields, allowing orchestrators to verify provenance programmatically: + +```json +{ + "success": true, + "data": { "snapshot": "...", "origin": "https://example.com" }, + "_boundary": { "nonce": "a1b2c3d4e5f6...", "origin": "https://example.com" } +} +``` + +## Domain Allowlist + +Restrict which domains the browser can interact with, preventing redirect-based attacks and data exfiltration: + +```bash +agent-browser --allowed-domains "example.com,*.example.com,github.com" open https://example.com +# or +export AGENT_BROWSER_ALLOWED_DOMAINS="example.com,*.example.com" +``` + +Supports exact match (`github.com`) and wildcard prefix (`*.example.com`, which also matches the bare domain `example.com`). Both page navigations and sub-resource requests (scripts, images, fetch, XHR, etc.) to non-allowed domains are blocked, preventing data exfiltration. WebSocket and EventSource connections are also blocked via constructor-level patching. Non-http(s) sub-resources (data URIs, blobs) are still allowed. When a request is blocked, the command returns an error. + +> **Note:** The WebSocket/EventSource blocking is best-effort -- it works by overriding the browser constructors via an init script. If the `eval` action category is allowed, page scripts could theoretically restore the original constructors. For maximum protection, deny the `eval` category via `--action-policy` when using `--allowed-domains`. + +Config file: + +```json +{ + "allowedDomains": ["example.com", "*.example.com", "github.com"] +} +``` + +> **CDN and third-party resources:** The domain filter blocks all sub-resource requests (scripts, stylesheets, images, fonts, fetch/XHR) to non-allowed domains. Most websites load assets from CDN domains. Include these in your allowlist or pages will break. For example: +> +> ```bash +> --allowed-domains "myapp.com,*.myapp.com,cdn.jsdelivr.net,fonts.googleapis.com,fonts.gstatic.com" +> ``` + +## Action Policy + +Gate actions using a static policy file. The policy is enforced by the daemon -- denied actions fail immediately. + +```bash +agent-browser --action-policy ./policy.json open https://example.com +# or +export AGENT_BROWSER_ACTION_POLICY=./policy.json +``` + +Example policy (permissive with specific denials): + +```json +{ + "default": "allow", + "deny": ["eval", "download", "upload"] +} +``` + +Example policy (restrictive): + +```json +{ + "default": "deny", + "allow": ["navigate", "snapshot", "click", "scroll", "wait", "get"] +} +``` + + + + + + + + + + + + + + + + + + + + +
CategoryActions
navigateopen, back, forward, reload, tab new
clickclick, dblclick, tap
fillfill, type, keyboard type/inserttext, select, check, uncheck
evaleval, evalhandle, addscript, addinitscript, addstyle, expose, setcontent
downloaddownload, waitfordownload
uploadupload
snapshotsnapshot, screenshot, pdf, diff
scrollscroll, scrollintoview
waitwait, waitforurl, waitforloadstate, waitforfunction
getget text/html/url/title, count, isvisible, getbyrole, getbytext, getbylabel, etc.
interacthover, focus, drag, press, keydown, keyup, mousemove, dispatch
networknetwork route/unroute, requests
statestate save/load, cookies set, storage set
+ +Auth vault operations (`auth save`, `auth login`, `auth list`, `auth show`, `auth delete`) and other internal/meta operations bypass action policy enforcement since they are trusted local operations. Domain allowlist restrictions still apply to `auth login` navigations. + +## Action Confirmation + +For actions that require explicit approval, use `--confirm-actions` to specify categories that require confirmation: + +```bash +# Orchestrator mode: returns confirmation_required response +agent-browser --confirm-actions eval,download eval "document.title" + +# Then approve or deny: +agent-browser confirm c_8f3a1234 +agent-browser deny c_8f3a1234 +``` + +For interactive (human-in-the-loop) confirmation: + +```bash +agent-browser --confirm-actions eval,download --confirm-interactive eval "document.title" +# Prompts: Allow? [y/N] +``` + +Pending confirmations auto-deny after 60 seconds. + +> **Non-TTY behavior:** When `--confirm-interactive` is set but stdin is not a TTY (e.g., piped input or running inside an automated pipeline), actions are automatically denied. This prevents accidental approval in non-interactive contexts. + +## Output Length Limits + +Prevent context flooding by truncating large page outputs: + +```bash +agent-browser --max-output 50000 get text body +# or +export AGENT_BROWSER_MAX_OUTPUT=50000 +``` + +Affected output types: `snapshot`, `get text`, `get html`, `eval`, `console`. + +## Environment Variables + + + + + + + + + + + + + + +
VariableDescription
AGENT_BROWSER_CONTENT_BOUNDARIESWrap page output in boundary markers
AGENT_BROWSER_MAX_OUTPUTMax characters for page output
AGENT_BROWSER_ALLOWED_DOMAINSComma-separated allowed domain patterns
AGENT_BROWSER_ACTION_POLICYPath to action policy JSON file
AGENT_BROWSER_CONFIRM_ACTIONSComma-separated action categories requiring confirmation
AGENT_BROWSER_CONFIRM_INTERACTIVEEnable interactive confirmation prompts
AGENT_BROWSER_ENCRYPTION_KEY64-char hex key for AES-256-GCM encryption (auth vault + sessions)
+ +## Recommended Configuration + +For production AI agent deployments: + +```json +{ + "contentBoundaries": true, + "maxOutput": 50000, + "allowedDomains": ["your-app.com", "*.your-app.com"], + "actionPolicy": "./policy.json" +} +``` diff --git a/docs/src/lib/docs-navigation.ts b/docs/src/lib/docs-navigation.ts index 4772231..4cf4b14 100644 --- a/docs/src/lib/docs-navigation.ts +++ b/docs/src/lib/docs-navigation.ts @@ -35,6 +35,7 @@ export const navigation: NavSection[] = [ { name: "Streaming", href: "/streaming" }, { name: "Profiler", href: "/profiler" }, { name: "iOS Simulator", href: "/ios" }, + { name: "Security", href: "/security" }, ], }, { diff --git a/docs/src/lib/page-titles.ts b/docs/src/lib/page-titles.ts index a43ff5e..379315b 100644 --- a/docs/src/lib/page-titles.ts +++ b/docs/src/lib/page-titles.ts @@ -12,6 +12,7 @@ export const PAGE_TITLES: Record = { streaming: "Streaming", profiler: "Profiler", ios: "iOS Simulator", + security: "Security", changelog: "Changelog", }; diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index a7a341a..4a93e3e 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -120,6 +120,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 @@ -305,6 +321,56 @@ agent-browser -p ios close **Real devices:** Works with physical iOS devices if pre-configured. Use `--device ""` where UDID is from `xcrun xctrace list devices`. +## Security + +All security features are opt-in. By default, agent-browser imposes no restrictions on navigation, actions, or output. + +### Content Boundaries (Recommended for AI Agents) + +Enable `--content-boundaries` to wrap page-sourced output in markers that help LLMs distinguish tool output from untrusted page content: + +```bash +export AGENT_BROWSER_CONTENT_BOUNDARIES=1 +agent-browser snapshot +# Output: +# --- AGENT_BROWSER_PAGE_CONTENT nonce= origin=https://example.com --- +# [accessibility tree] +# --- END_AGENT_BROWSER_PAGE_CONTENT nonce= --- +``` + +### Domain Allowlist + +Restrict navigation to trusted domains. Wildcards like `*.example.com` also match the bare domain `example.com`. Sub-resource requests, WebSocket, and EventSource connections to non-allowed domains are also blocked. Include CDN domains your target pages depend on: + +```bash +export AGENT_BROWSER_ALLOWED_DOMAINS="example.com,*.example.com" +agent-browser open https://example.com # OK +agent-browser open https://malicious.com # Blocked +``` + +### Action Policy + +Use a policy file to gate destructive actions: + +```bash +export AGENT_BROWSER_ACTION_POLICY=./policy.json +``` + +Example `policy.json`: +```json +{"default": "deny", "allow": ["navigate", "snapshot", "click", "scroll", "wait", "get"]} +``` + +Auth vault operations (`auth login`, etc.) bypass action policy but domain allowlist still applies. + +### Output Limits + +Prevent context flooding from large pages: + +```bash +export AGENT_BROWSER_MAX_OUTPUT=50000 +``` + ## Diffing (Verifying Changes) Use `diff snapshot` after performing an action to verify it had the intended effect. This compares the current accessibility tree against the last snapshot taken in the session. diff --git a/skills/agent-browser/templates/authenticated-session.sh b/skills/agent-browser/templates/authenticated-session.sh index f9984c6..b66c928 100755 --- a/skills/agent-browser/templates/authenticated-session.sh +++ b/skills/agent-browser/templates/authenticated-session.sh @@ -3,6 +3,11 @@ # Purpose: Login once, save state, reuse for subsequent runs # Usage: ./authenticated-session.sh [state-file] # +# RECOMMENDED: Use the auth vault instead of this template: +# echo "" | agent-browser auth save myapp --url --username --password-stdin +# agent-browser auth login myapp +# The auth vault stores credentials securely and the LLM never sees passwords. +# # Environment variables: # APP_USERNAME - Login username/email # APP_PASSWORD - Login password diff --git a/src/action-policy.test.ts b/src/action-policy.test.ts new file mode 100644 index 0000000..edc68fe --- /dev/null +++ b/src/action-policy.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + getActionCategory, + checkPolicy, + loadPolicyFile, + describeAction, + KNOWN_CATEGORIES, + type ActionPolicy, +} from './action-policy.js'; + +describe('action-policy', () => { + describe('getActionCategory', () => { + it('should return correct category for known actions', () => { + expect(getActionCategory('navigate')).toBe('navigate'); + expect(getActionCategory('click')).toBe('click'); + expect(getActionCategory('fill')).toBe('fill'); + expect(getActionCategory('evaluate')).toBe('eval'); + expect(getActionCategory('download')).toBe('download'); + expect(getActionCategory('upload')).toBe('upload'); + expect(getActionCategory('snapshot')).toBe('snapshot'); + expect(getActionCategory('scroll')).toBe('scroll'); + expect(getActionCategory('wait')).toBe('wait'); + expect(getActionCategory('gettext')).toBe('get'); + expect(getActionCategory('route')).toBe('network'); + expect(getActionCategory('state_save')).toBe('state'); + expect(getActionCategory('hover')).toBe('interact'); + }); + + it('should return _internal for internal actions', () => { + expect(getActionCategory('launch')).toBe('_internal'); + expect(getActionCategory('close')).toBe('_internal'); + expect(getActionCategory('session')).toBe('_internal'); + expect(getActionCategory('auth_save')).toBe('_internal'); + expect(getActionCategory('confirm')).toBe('_internal'); + }); + + it('should return eval for security-sensitive actions', () => { + expect(getActionCategory('setcontent')).toBe('eval'); + expect(getActionCategory('expose')).toBe('eval'); + expect(getActionCategory('addstyle')).toBe('eval'); + }); + + it('should return unknown for unrecognized actions', () => { + expect(getActionCategory('nonexistent')).toBe('unknown'); + expect(getActionCategory('')).toBe('unknown'); + }); + + it('should return get for semantic locator actions', () => { + expect(getActionCategory('getbyrole')).toBe('get'); + expect(getActionCategory('getbytext')).toBe('get'); + expect(getActionCategory('getbylabel')).toBe('get'); + }); + }); + + describe('checkPolicy', () => { + it('should always allow internal actions regardless of policy', () => { + const denyAll: ActionPolicy = { default: 'deny' }; + expect(checkPolicy('launch', denyAll, new Set())).toBe('allow'); + expect(checkPolicy('close', denyAll, new Set())).toBe('allow'); + expect(checkPolicy('session', denyAll, new Set())).toBe('allow'); + }); + + it('should allow all when no policy and no confirm categories', () => { + expect(checkPolicy('navigate', null, new Set())).toBe('allow'); + expect(checkPolicy('click', null, new Set())).toBe('allow'); + expect(checkPolicy('evaluate', null, new Set())).toBe('allow'); + }); + + it('should deny actions in explicit deny list', () => { + const policy: ActionPolicy = { default: 'allow', deny: ['eval', 'download'] }; + expect(checkPolicy('evaluate', policy, new Set())).toBe('deny'); + expect(checkPolicy('download', policy, new Set())).toBe('deny'); + expect(checkPolicy('click', policy, new Set())).toBe('allow'); + }); + + it('should allow actions in explicit allow list with deny default', () => { + const policy: ActionPolicy = { default: 'deny', allow: ['navigate', 'snapshot'] }; + expect(checkPolicy('navigate', policy, new Set())).toBe('allow'); + expect(checkPolicy('snapshot', policy, new Set())).toBe('allow'); + expect(checkPolicy('click', policy, new Set())).toBe('deny'); + }); + + it('should return confirm for actions in confirm categories', () => { + expect(checkPolicy('evaluate', null, new Set(['eval']))).toBe('confirm'); + expect(checkPolicy('download', null, new Set(['download']))).toBe('confirm'); + }); + + it('should deny over confirm when action is in deny list', () => { + const policy: ActionPolicy = { default: 'allow', deny: ['eval'] }; + expect(checkPolicy('evaluate', policy, new Set(['eval']))).toBe('deny'); + }); + + it('should use default policy for unknown categories', () => { + const denyPolicy: ActionPolicy = { default: 'deny' }; + const allowPolicy: ActionPolicy = { default: 'allow' }; + expect(checkPolicy('nonexistent', denyPolicy, new Set())).toBe('deny'); + expect(checkPolicy('nonexistent', allowPolicy, new Set())).toBe('allow'); + }); + }); + + describe('loadPolicyFile', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'action-policy-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should load a valid allow-default policy', () => { + const policyPath = path.join(tempDir, 'policy.json'); + fs.writeFileSync(policyPath, JSON.stringify({ default: 'allow', deny: ['eval'] })); + const policy = loadPolicyFile(policyPath); + expect(policy.default).toBe('allow'); + expect(policy.deny).toEqual(['eval']); + }); + + it('should load a valid deny-default policy', () => { + const policyPath = path.join(tempDir, 'policy.json'); + fs.writeFileSync( + policyPath, + JSON.stringify({ default: 'deny', allow: ['navigate', 'snapshot'] }) + ); + const policy = loadPolicyFile(policyPath); + expect(policy.default).toBe('deny'); + expect(policy.allow).toEqual(['navigate', 'snapshot']); + }); + + it('should throw on invalid default value', () => { + const policyPath = path.join(tempDir, 'policy.json'); + fs.writeFileSync(policyPath, JSON.stringify({ default: 'maybe' })); + expect(() => loadPolicyFile(policyPath)).toThrow('must be "allow" or "deny"'); + }); + + it('should throw on missing file', () => { + expect(() => loadPolicyFile(path.join(tempDir, 'missing.json'))).toThrow(); + }); + + it('should throw on invalid JSON', () => { + const policyPath = path.join(tempDir, 'policy.json'); + fs.writeFileSync(policyPath, 'not json'); + expect(() => loadPolicyFile(policyPath)).toThrow(); + }); + + it('should warn on unrecognized category names', () => { + const policyPath = path.join(tempDir, 'policy.json'); + fs.writeFileSync( + policyPath, + JSON.stringify({ default: 'allow', deny: ['eval', 'typo_category'] }) + ); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const policy = loadPolicyFile(policyPath); + expect(policy.default).toBe('allow'); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('unrecognized action category "typo_category"') + ); + warnSpy.mockRestore(); + }); + + it('should not warn on valid category names', () => { + const policyPath = path.join(tempDir, 'policy.json'); + fs.writeFileSync( + policyPath, + JSON.stringify({ default: 'deny', allow: ['navigate', 'snapshot', 'get'] }) + ); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + loadPolicyFile(policyPath); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + }); + + describe('describeAction', () => { + it('should describe navigate actions', () => { + expect(describeAction('navigate', { url: 'https://example.com' })).toBe( + 'Navigate to https://example.com' + ); + }); + + it('should describe eval actions with truncation', () => { + const longScript = 'a'.repeat(200); + const desc = describeAction('evaluate', { script: longScript }); + expect(desc).toContain('Evaluate JavaScript:'); + expect(desc.length).toBeLessThan(200); + }); + + it('should describe click actions', () => { + expect(describeAction('click', { selector: '#btn' })).toBe('Click #btn'); + }); + + it('should describe dblclick actions', () => { + expect(describeAction('dblclick', { selector: '#btn' })).toBe('Double-click #btn'); + }); + + it('should describe tap actions', () => { + expect(describeAction('tap', { selector: '#btn' })).toBe('Tap #btn'); + }); + + it('should describe fill actions', () => { + expect(describeAction('fill', { selector: '#input' })).toBe('Fill #input'); + }); + + it('should use fallback for unknown actions', () => { + const desc = describeAction('scroll', {}); + expect(desc).toContain('scroll'); + }); + }); +}); diff --git a/src/action-policy.ts b/src/action-policy.ts new file mode 100644 index 0000000..b9847d2 --- /dev/null +++ b/src/action-policy.ts @@ -0,0 +1,297 @@ +import { readFileSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; + +export interface ActionPolicy { + default: 'allow' | 'deny'; + allow?: string[]; + deny?: string[]; +} + +export type PolicyDecision = 'allow' | 'deny' | 'confirm'; + +const ACTION_CATEGORIES: Record = { + navigate: 'navigate', + back: 'navigate', + forward: 'navigate', + reload: 'navigate', + tab_new: 'navigate', + + click: 'click', + dblclick: 'click', + tap: 'click', + + fill: 'fill', + type: 'fill', + // The `keyboard` action is a compound command that dispatches to sub-actions + // (type, inserttext, press, down, up). Its primary use is text input, so it + // maps to 'fill'. The interact-like sub-actions (press, down, up) are less + // common and don't have separate top-level action names in the protocol. + keyboard: 'fill', + inserttext: 'fill', + select: 'fill', + multiselect: 'fill', + check: 'fill', + uncheck: 'fill', + clear: 'fill', + selectall: 'fill', + setvalue: 'fill', + + download: 'download', + waitfordownload: 'download', + + upload: 'upload', + + evaluate: 'eval', + evalhandle: 'eval', + addscript: 'eval', + addinitscript: 'eval', + + snapshot: 'snapshot', + screenshot: 'snapshot', + pdf: 'snapshot', + diff_snapshot: 'snapshot', + diff_screenshot: 'snapshot', + diff_url: 'snapshot', + + scroll: 'scroll', + scrollintoview: 'scroll', + + wait: 'wait', + waitforurl: 'wait', + waitforloadstate: 'wait', + waitforfunction: 'wait', + + gettext: 'get', + content: 'get', + innerhtml: 'get', + innertext: 'get', + inputvalue: 'get', + url: 'get', + title: 'get', + getattribute: 'get', + count: 'get', + boundingbox: 'get', + styles: 'get', + isvisible: 'get', + isenabled: 'get', + ischecked: 'get', + responsebody: 'get', + + route: 'network', + unroute: 'network', + requests: 'network', + + state_save: 'state', + state_load: 'state', + cookies_set: 'state', + storage_set: 'state', + credentials: 'state', + + hover: 'interact', + focus: 'interact', + drag: 'interact', + press: 'interact', + keydown: 'interact', + keyup: 'interact', + mousemove: 'interact', + mousedown: 'interact', + mouseup: 'interact', + wheel: 'interact', + dispatch: 'interact', + + // These are always allowed (internal/meta operations) + launch: '_internal', + close: '_internal', + tab_list: '_internal', + tab_switch: '_internal', + tab_close: '_internal', + window_new: '_internal', + frame: '_internal', + mainframe: '_internal', + dialog: '_internal', + session: '_internal', + console: '_internal', + errors: '_internal', + cookies_get: '_internal', + cookies_clear: '_internal', + storage_get: '_internal', + storage_clear: '_internal', + state_list: '_internal', + state_show: '_internal', + state_clear: '_internal', + state_clean: '_internal', + state_rename: '_internal', + highlight: '_internal', + bringtofront: '_internal', + trace_start: '_internal', + trace_stop: '_internal', + har_start: '_internal', + har_stop: '_internal', + video_start: '_internal', + video_stop: '_internal', + recording_start: '_internal', + recording_stop: '_internal', + recording_restart: '_internal', + profiler_start: '_internal', + profiler_stop: '_internal', + clipboard: '_internal', + viewport: '_internal', + useragent: '_internal', + device: '_internal', + geolocation: '_internal', + permissions: '_internal', + emulatemedia: '_internal', + offline: '_internal', + headers: '_internal', + addstyle: 'eval', + expose: 'eval', + timezone: '_internal', + locale: '_internal', + pause: '_internal', + setcontent: 'eval', + screencast_start: '_internal', + screencast_stop: '_internal', + input_mouse: '_internal', + input_keyboard: '_internal', + input_touch: '_internal', + + auth_save: '_internal', + auth_login: '_internal', + auth_list: '_internal', + auth_delete: '_internal', + auth_show: '_internal', + confirm: '_internal', + deny: '_internal', + + // Find/semantic locator actions (read-only element resolution) + getbyrole: 'get', + getbytext: 'get', + getbylabel: 'get', + getbyplaceholder: 'get', + getbyalttext: 'get', + getbytitle: 'get', + getbytestid: 'get', + nth: 'get', +}; + +// User-facing categories used in policy files. '_internal' is excluded because +// internal actions always bypass policy. 'unknown' is intentionally not a value +// in ACTION_CATEGORIES -- it is only the fallback return of getActionCategory() +// for unrecognized actions. If a user puts "unknown" in a policy file, +// loadPolicyFile will warn about it as unrecognized, which is correct. +export const KNOWN_CATEGORIES = new Set( + Object.values(ACTION_CATEGORIES).filter((c) => c !== '_internal') +); + +export function getActionCategory(action: string): string { + return ACTION_CATEGORIES[action] ?? 'unknown'; +} + +export function loadPolicyFile(policyPath: string): ActionPolicy { + const resolved = resolve(policyPath); + const content = readFileSync(resolved, 'utf-8'); + const policy = JSON.parse(content) as ActionPolicy; + + if (policy.default !== 'allow' && policy.default !== 'deny') { + throw new Error( + `Invalid action policy: "default" must be "allow" or "deny", got "${policy.default}"` + ); + } + + for (const list of [policy.allow, policy.deny]) { + if (!list) continue; + for (const category of list) { + if (!KNOWN_CATEGORIES.has(category)) { + console.warn( + `[agent-browser] Warning: unrecognized action category "${category}" in policy file. ` + + `Known categories: ${[...KNOWN_CATEGORIES].sort().join(', ')}` + ); + } + } + } + + return policy; +} + +let cachedPolicyPath: string | null = null; +let cachedPolicyMtimeMs = 0; +let cachedPolicy: ActionPolicy | null = null; +const RELOAD_CHECK_INTERVAL_MS = 5_000; +let lastCheckMs = 0; + +export function initPolicyReloader(policyPath: string, policy: ActionPolicy): void { + cachedPolicyPath = resolve(policyPath); + cachedPolicyMtimeMs = statSync(cachedPolicyPath).mtimeMs; + cachedPolicy = policy; +} + +export function reloadPolicyIfChanged(): ActionPolicy | null { + if (!cachedPolicyPath) return cachedPolicy; + + const now = Date.now(); + if (now - lastCheckMs < RELOAD_CHECK_INTERVAL_MS) return cachedPolicy; + lastCheckMs = now; + + try { + const currentMtime = statSync(cachedPolicyPath).mtimeMs; + if (currentMtime !== cachedPolicyMtimeMs) { + cachedPolicy = loadPolicyFile(cachedPolicyPath); + cachedPolicyMtimeMs = currentMtime; + } + } catch { + // File may have been removed; keep using cached policy + } + + return cachedPolicy; +} + +export function checkPolicy( + action: string, + policy: ActionPolicy | null, + confirmCategories: Set +): PolicyDecision { + const category = getActionCategory(action); + + // Internal actions are always allowed + if (category === '_internal') return 'allow'; + + // Explicit deny takes precedence over confirmation + if (policy?.deny?.includes(category)) return 'deny'; + + // Check if this category requires confirmation + if (confirmCategories.has(category)) return 'confirm'; + + if (!policy) return 'allow'; + + // Explicit allow list + if (policy.allow?.includes(category)) return 'allow'; + + return policy.default; +} + +export function describeAction(action: string, command: Record): string { + const category = getActionCategory(action); + switch (action) { + case 'navigate': + return `Navigate to ${command.url}`; + case 'evaluate': + case 'evalhandle': + return `Evaluate JavaScript: ${String(command.script ?? '').slice(0, 80)}`; + case 'fill': + return `Fill ${command.selector}`; + case 'type': + return `Type into ${command.selector}`; + case 'click': + return `Click ${command.selector}`; + case 'dblclick': + return `Double-click ${command.selector}`; + case 'tap': + return `Tap ${command.selector}`; + case 'download': + return `Download via ${command.selector} to ${command.path}`; + case 'upload': + return `Upload files to ${command.selector}`; + default: + return `${category}: ${action}`; + } +} diff --git a/src/auth-cli.ts b/src/auth-cli.ts new file mode 100644 index 0000000..61f3df0 --- /dev/null +++ b/src/auth-cli.ts @@ -0,0 +1,120 @@ +/** + * Standalone CLI entry point for auth vault operations that don't need a browser. + * Invoked directly by the Rust CLI to avoid sending passwords through the daemon channel. + * + * Usage: node auth-cli.js + * Prints a JSON response to stdout and exits. + */ +import { + saveAuthProfile, + getAuthProfileMeta, + listAuthProfiles, + deleteAuthProfile, +} from './auth-vault.js'; + +interface AuthCommand { + id: string; + action: string; + name?: string; + url?: string; + username?: string; + password?: string; + usernameSelector?: string; + passwordSelector?: string; + submitSelector?: string; +} + +function success(id: string, data: Record): string { + return JSON.stringify({ success: true, id, data }); +} + +function error(id: string, message: string): string { + return JSON.stringify({ success: false, id, error: message }); +} + +function run(): void { + const input = process.argv[2]; + if (!input) { + process.stderr.write('Usage: node auth-cli.js \n'); + process.exit(1); + } + + let cmd: AuthCommand; + try { + cmd = JSON.parse(input); + } catch { + console.log(error('', 'Invalid JSON input')); + process.exit(1); + return; + } + + const id = cmd.id || ''; + + try { + switch (cmd.action) { + case 'auth_save': { + if (!cmd.name || !cmd.url || !cmd.username || !cmd.password) { + console.log(error(id, 'Missing required fields: name, url, username, password')); + return; + } + const meta = saveAuthProfile({ + name: cmd.name, + url: cmd.url, + username: cmd.username, + password: cmd.password, + usernameSelector: cmd.usernameSelector, + passwordSelector: cmd.passwordSelector, + submitSelector: cmd.submitSelector, + }); + console.log( + success(id, { + saved: !meta.updated, + updated: meta.updated, + name: meta.name, + url: meta.url, + username: meta.username, + }) + ); + return; + } + case 'auth_list': { + const profiles = listAuthProfiles(); + console.log(success(id, { profiles })); + return; + } + case 'auth_show': { + if (!cmd.name) { + console.log(error(id, 'Missing required field: name')); + return; + } + const meta = getAuthProfileMeta(cmd.name); + if (!meta) { + console.log(error(id, `Auth profile '${cmd.name}' not found`)); + return; + } + console.log(success(id, { profile: meta })); + return; + } + case 'auth_delete': { + if (!cmd.name) { + console.log(error(id, 'Missing required field: name')); + return; + } + const deleted = deleteAuthProfile(cmd.name); + if (!deleted) { + console.log(error(id, `Auth profile '${cmd.name}' not found`)); + return; + } + console.log(success(id, { deleted: true, name: cmd.name })); + return; + } + default: + console.log(error(id, `Unknown auth action: ${cmd.action}`)); + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Operation failed'; + console.log(error(id, msg)); + } +} + +run(); diff --git a/src/auth-vault.test.ts b/src/auth-vault.test.ts new file mode 100644 index 0000000..5e298fe --- /dev/null +++ b/src/auth-vault.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +let tempHome: string; + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { + ...actual, + homedir: () => tempHome, + }, + homedir: () => tempHome, + }; +}); + +import { + saveAuthProfile, + getAuthProfile, + getAuthProfileMeta, + listAuthProfiles, + deleteAuthProfile, + updateLastLogin, +} from './auth-vault.js'; + +describe('auth-vault', () => { + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-browser-auth-test-')); + delete process.env.AGENT_BROWSER_ENCRYPTION_KEY; + }); + + afterEach(() => { + try { + fs.rmSync(tempHome, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + }); + + function cleanAuthDir() { + const authDir = path.join(tempHome, '.agent-browser', 'auth'); + if (fs.existsSync(authDir)) { + for (const f of fs.readdirSync(authDir)) { + fs.unlinkSync(path.join(authDir, f)); + } + } + } + + describe('saveAuthProfile', () => { + it('should save a new profile', () => { + const result = saveAuthProfile({ + name: 'github', + url: 'https://github.com/login', + username: 'user', + password: 'pass', + }); + + expect(result.name).toBe('github'); + expect(result.url).toBe('https://github.com/login'); + expect(result.username).toBe('user'); + expect(result.updated).toBe(false); + expect(result.createdAt).toBeTruthy(); + }); + + it('should mark as updated when overwriting', () => { + saveAuthProfile({ + name: 'github', + url: 'https://github.com/login', + username: 'user1', + password: 'pass1', + }); + + const result = saveAuthProfile({ + name: 'github', + url: 'https://github.com/login', + username: 'user2', + password: 'pass2', + }); + + expect(result.updated).toBe(true); + expect(result.username).toBe('user2'); + }); + + it('should preserve createdAt on update', () => { + const first = saveAuthProfile({ + name: 'github', + url: 'https://github.com/login', + username: 'user', + password: 'pass', + }); + + const second = saveAuthProfile({ + name: 'github', + url: 'https://github.com/login', + username: 'user2', + password: 'pass2', + }); + + expect(second.createdAt).toBe(first.createdAt); + }); + + it('should save with custom selectors', () => { + saveAuthProfile({ + name: 'myapp', + url: 'https://example.com/login', + username: 'user', + password: 'pass', + usernameSelector: '#email', + passwordSelector: '#password', + submitSelector: 'button.login', + }); + + const profile = getAuthProfile('myapp'); + expect(profile).not.toBeNull(); + expect(profile!.usernameSelector).toBe('#email'); + expect(profile!.passwordSelector).toBe('#password'); + expect(profile!.submitSelector).toBe('button.login'); + }); + + it('should reject invalid profile names', () => { + expect(() => + saveAuthProfile({ + name: '../escape', + url: 'https://example.com', + username: 'user', + password: 'pass', + }) + ).toThrow('only alphanumeric'); + }); + }); + + describe('getAuthProfile', () => { + it('should return null for non-existent profile', () => { + expect(getAuthProfile('nonexistent')).toBeNull(); + }); + + it('should return full profile with password', () => { + saveAuthProfile({ + name: 'test', + url: 'https://example.com', + username: 'user', + password: 'secret', + }); + + const profile = getAuthProfile('test'); + expect(profile).not.toBeNull(); + expect(profile!.password).toBe('secret'); + }); + }); + + describe('getAuthProfileMeta', () => { + it('should return metadata without password', () => { + saveAuthProfile({ + name: 'test', + url: 'https://example.com', + username: 'user', + password: 'secret', + }); + + const meta = getAuthProfileMeta('test'); + expect(meta).not.toBeNull(); + expect(meta!.name).toBe('test'); + expect(meta!.username).toBe('user'); + expect((meta as Record).password).toBeUndefined(); + }); + + it('should return null for non-existent profile', () => { + expect(getAuthProfileMeta('nonexistent')).toBeNull(); + }); + }); + + describe('listAuthProfiles', () => { + it('should return empty array when no profiles', () => { + cleanAuthDir(); + expect(listAuthProfiles()).toEqual([]); + }); + + it('should list all saved profiles', () => { + cleanAuthDir(); + saveAuthProfile({ + name: 'github', + url: 'https://github.com/login', + username: 'user1', + password: 'pass1', + }); + saveAuthProfile({ + name: 'gitlab', + url: 'https://gitlab.com/login', + username: 'user2', + password: 'pass2', + }); + + const profiles = listAuthProfiles(); + expect(profiles).toHaveLength(2); + const names = profiles.map((p) => p.name).sort(); + expect(names).toEqual(['github', 'gitlab']); + }); + }); + + describe('deleteAuthProfile', () => { + it('should delete an existing profile', () => { + saveAuthProfile({ + name: 'test', + url: 'https://example.com', + username: 'user', + password: 'pass', + }); + + expect(deleteAuthProfile('test')).toBe(true); + expect(getAuthProfile('test')).toBeNull(); + }); + + it('should return false for non-existent profile', () => { + expect(deleteAuthProfile('nonexistent')).toBe(false); + }); + }); + + describe('updateLastLogin', () => { + it('should update lastLoginAt timestamp', () => { + saveAuthProfile({ + name: 'test', + url: 'https://example.com', + username: 'user', + password: 'pass', + }); + + const metaBefore = getAuthProfileMeta('test'); + expect(metaBefore!.lastLoginAt).toBeUndefined(); + + updateLastLogin('test'); + + const metaAfter = getAuthProfileMeta('test'); + expect(metaAfter!.lastLoginAt).toBeTruthy(); + }); + }); + + describe('auto-generated encryption key', () => { + it('should auto-create key file and encrypt profile when no env var is set', () => { + delete process.env.AGENT_BROWSER_ENCRYPTION_KEY; + + saveAuthProfile({ + name: 'autokey', + url: 'https://example.com', + username: 'user', + password: 'secret', + }); + + const keyFilePath = path.join(tempHome, '.agent-browser', '.encryption-key'); + expect(fs.existsSync(keyFilePath)).toBe(true); + + const keyHex = fs.readFileSync(keyFilePath, 'utf-8').trim(); + expect(keyHex).toMatch(/^[a-f0-9]{64}$/); + + const profilePath = path.join(tempHome, '.agent-browser', 'auth', 'autokey.json'); + const raw = JSON.parse(fs.readFileSync(profilePath, 'utf-8')); + expect(raw.encrypted).toBe(true); + expect(raw.iv).toBeTruthy(); + }); + + it('should read back profile using auto-generated key', () => { + delete process.env.AGENT_BROWSER_ENCRYPTION_KEY; + + saveAuthProfile({ + name: 'readback', + url: 'https://example.com', + username: 'user', + password: 'secret123', + }); + + const profile = getAuthProfile('readback'); + expect(profile).not.toBeNull(); + expect(profile!.password).toBe('secret123'); + }); + }); +}); diff --git a/src/auth-vault.ts b/src/auth-vault.ts new file mode 100644 index 0000000..577b4fd --- /dev/null +++ b/src/auth-vault.ts @@ -0,0 +1,189 @@ +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + readdirSync, + unlinkSync, +} from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { + getEncryptionKey, + ensureEncryptionKey, + encryptData, + decryptData, + isEncryptedPayload, + getKeyFilePath, + restrictFilePermissions, + restrictDirPermissions, + type EncryptedPayload, +} from './encryption.js'; + +const AUTH_DIR = 'auth'; + +interface AuthProfile { + name: string; + url: string; + username: string; + password: string; + usernameSelector?: string; + passwordSelector?: string; + submitSelector?: string; + createdAt: string; + lastLoginAt?: string; +} + +export interface AuthProfileMeta { + name: string; + url: string; + username: string; + createdAt: string; + lastLoginAt?: string; +} + +function getAuthDir(): string { + const dir = path.join(os.homedir(), '.agent-browser', AUTH_DIR); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + restrictDirPermissions(dir); + } + return dir; +} + +const SAFE_NAME_RE = /^[a-zA-Z0-9_-]+$/; + +function validateProfileName(name: string): void { + if (!SAFE_NAME_RE.test(name)) { + throw new Error( + `Invalid auth profile name '${name}': only alphanumeric characters, hyphens, and underscores are allowed` + ); + } +} + +function profilePath(name: string): string { + validateProfileName(name); + return path.join(getAuthDir(), `${name}.json`); +} + +function readProfile(name: string): AuthProfile | null { + const p = profilePath(name); + if (!existsSync(p)) return null; + + const raw = readFileSync(p, 'utf-8'); + const parsed = JSON.parse(raw); + + if (isEncryptedPayload(parsed)) { + const key = getEncryptionKey(); + if (!key) { + throw new Error( + `Encryption key required to read encrypted auth profiles. ` + + `Set AGENT_BROWSER_ENCRYPTION_KEY or ensure ${getKeyFilePath()} exists.` + ); + } + const decrypted = decryptData(parsed as EncryptedPayload, key); + return JSON.parse(decrypted) as AuthProfile; + } + + return parsed as AuthProfile; +} + +function writeProfile(profile: AuthProfile): void { + const key = ensureEncryptionKey(); + const serialized = JSON.stringify(profile, null, 2); + const encrypted = encryptData(serialized, key); + const filePath = profilePath(profile.name); + writeFileSync(filePath, JSON.stringify(encrypted, null, 2), { + mode: 0o600, + }); + restrictFilePermissions(filePath); +} + +export function saveAuthProfile(opts: { + name: string; + url: string; + username: string; + password: string; + usernameSelector?: string; + passwordSelector?: string; + submitSelector?: string; +}): AuthProfileMeta & { updated: boolean } { + const existing = readProfile(opts.name); + + const profile: AuthProfile = { + name: opts.name, + url: opts.url, + username: opts.username, + password: opts.password, + usernameSelector: opts.usernameSelector, + passwordSelector: opts.passwordSelector, + submitSelector: opts.submitSelector, + createdAt: existing?.createdAt ?? new Date().toISOString(), + lastLoginAt: existing?.lastLoginAt, + }; + + writeProfile(profile); + + return { + name: profile.name, + url: profile.url, + username: profile.username, + createdAt: profile.createdAt, + lastLoginAt: profile.lastLoginAt, + updated: existing !== null, + }; +} + +export function getAuthProfile(name: string): AuthProfile | null { + return readProfile(name); +} + +export function getAuthProfileMeta(name: string): AuthProfileMeta | null { + const profile = readProfile(name); + if (!profile) return null; + return { + name: profile.name, + url: profile.url, + username: profile.username, + createdAt: profile.createdAt, + lastLoginAt: profile.lastLoginAt, + }; +} + +export function listAuthProfiles(): AuthProfileMeta[] { + const dir = getAuthDir(); + const files = readdirSync(dir).filter((f) => f.endsWith('.json')); + const profiles: AuthProfileMeta[] = []; + + for (const file of files) { + const name = file.replace(/\.json$/, ''); + try { + const meta = getAuthProfileMeta(name); + if (meta) profiles.push(meta); + } catch { + profiles.push({ + name, + url: '(encrypted)', + username: '(encrypted)', + createdAt: '(unknown)', + }); + } + } + + return profiles; +} + +export function deleteAuthProfile(name: string): boolean { + const p = profilePath(name); + if (!existsSync(p)) return false; + unlinkSync(p); + return true; +} + +export function updateLastLogin(name: string): void { + const profile = readProfile(name); + if (profile) { + profile.lastLoginAt = new Date().toISOString(); + writeProfile(profile); + } +} diff --git a/src/browser.ts b/src/browser.ts index e6f3e89..933398a 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -21,6 +21,7 @@ import { writeFile, mkdir } from 'node:fs/promises'; import type { LaunchCommand, TraceEvent } from './types.js'; import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js'; import { safeHeaderMerge } from './state-utils.js'; +import { isDomainAllowed, installDomainFilter, parseDomainList } from './domain-filter.js'; import { getEncryptionKey, isEncryptedPayload, @@ -161,6 +162,7 @@ export class BrowserManager { private contextHeaders: Record | undefined = undefined; private contextUserAgent: string | undefined = undefined; private downloadPath: string | null = null; + private allowedDomains: string[] = []; /** * Set the persistent color scheme preference. @@ -573,6 +575,61 @@ export class BrowserManager { return parseRef(selector) !== null; } + /** + * Install the domain filter on a context if an allowlist is configured. + * Should be called before any pages navigate on the context. + */ + private async ensureDomainFilter(context: BrowserContext): Promise { + if (this.allowedDomains.length > 0) { + await installDomainFilter(context, this.allowedDomains); + } + } + + /** + * After installing the domain filter, verify existing pages are on allowed + * domains. Pages that pre-date the filter (e.g. CDP/cloud connect) may have + * already navigated to disallowed domains. Navigate them to about:blank. + */ + private async sanitizeExistingPages(pages: Page[]): Promise { + if (this.allowedDomains.length === 0) return; + for (const page of pages) { + const url = page.url(); + if (!url || url === 'about:blank') continue; + try { + const hostname = new URL(url).hostname.toLowerCase(); + if (!isDomainAllowed(hostname, this.allowedDomains)) { + await page.goto('about:blank'); + } + } catch { + await page.goto('about:blank').catch(() => {}); + } + } + } + + /** + * Check if a URL is allowed by the domain allowlist. + * Throws if the URL's domain is blocked. No-op if no allowlist is set. + * Blocks non-http(s) schemes and unparseable URLs by default. + */ + checkDomainAllowed(url: string): void { + if (this.allowedDomains.length === 0) return; + + if (!url.startsWith('http://') && !url.startsWith('https://')) { + throw new Error(`Navigation blocked: non-http(s) scheme in URL "${url}"`); + } + + let hostname: string; + try { + hostname = new URL(url).hostname.toLowerCase(); + } catch { + throw new Error(`Navigation blocked: unable to parse URL "${url}"`); + } + + if (!isDomainAllowed(hostname, this.allowedDomains)) { + throw new Error(`Navigation blocked: ${hostname} is not in the allowed domains list`); + } + } + /** * Get locator - supports both refs and regular selectors */ @@ -645,6 +702,7 @@ export class BrowserManager { context.setDefaultTimeout(getDefaultTimeout()); this.contexts.push(context); this.setupContextTracking(context); + await this.ensureDomainFilter(context); } else { return; } @@ -1278,6 +1336,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); @@ -1421,10 +1481,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); @@ -1497,10 +1559,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); @@ -1572,6 +1636,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)"; @@ -1832,8 +1905,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); @@ -1930,8 +2005,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); @@ -2200,6 +2278,7 @@ export class BrowserManager { context.setDefaultTimeout(getDefaultTimeout()); this.contexts.push(context); this.setupContextTracking(context); + await this.ensureDomainFilter(context); const page = await context.newPage(); // Only add if not already tracked (setupContextTracking may have already added it via 'page' event) diff --git a/src/confirmation.test.ts b/src/confirmation.test.ts new file mode 100644 index 0000000..f2553ab --- /dev/null +++ b/src/confirmation.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { requestConfirmation, getAndRemovePending } from './confirmation.js'; + +describe('confirmation', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('requestConfirmation', () => { + it('should return a confirmation ID', () => { + const result = requestConfirmation('evaluate', 'eval', 'Evaluate JS', { script: 'test' }); + expect(result.confirmationId).toBeTruthy(); + expect(result.confirmationId).toMatch(/^c_[0-9a-f]{16}$/); + }); + + it('should generate unique IDs', () => { + const r1 = requestConfirmation('evaluate', 'eval', 'desc', {}); + const r2 = requestConfirmation('click', 'click', 'desc', {}); + expect(r1.confirmationId).not.toBe(r2.confirmationId); + }); + }); + + describe('getAndRemovePending', () => { + it('should retrieve and remove a pending confirmation', () => { + const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', { + action: 'evaluate', + script: 'test', + }); + + const entry = getAndRemovePending(confirmationId); + expect(entry).not.toBeNull(); + expect(entry!.action).toBe('evaluate'); + expect(entry!.command).toEqual({ action: 'evaluate', script: 'test' }); + }); + + it('should return null on second retrieval (already removed)', () => { + const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {}); + getAndRemovePending(confirmationId); + expect(getAndRemovePending(confirmationId)).toBeNull(); + }); + + it('should return null for non-existent ID', () => { + expect(getAndRemovePending('c_nonexistent')).toBeNull(); + }); + + it('should auto-deny after 60 seconds', () => { + const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {}); + + vi.advanceTimersByTime(60_000); + + expect(getAndRemovePending(confirmationId)).toBeNull(); + }); + + it('should still be retrievable before 60 second timeout', () => { + const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {}); + + vi.advanceTimersByTime(59_999); + + const entry = getAndRemovePending(confirmationId); + expect(entry).not.toBeNull(); + }); + }); +}); diff --git a/src/confirmation.ts b/src/confirmation.ts new file mode 100644 index 0000000..04b00cf --- /dev/null +++ b/src/confirmation.ts @@ -0,0 +1,53 @@ +import { randomBytes } from 'node:crypto'; + +interface PendingConfirmation { + id: string; + action: string; + category: string; + description: string; + command: Record; + timer: ReturnType; +} + +const AUTO_DENY_TIMEOUT_MS = 60_000; + +const pending = new Map(); + +function generateId(): string { + return `c_${randomBytes(8).toString('hex')}`; +} + +export function requestConfirmation( + action: string, + category: string, + description: string, + command: Record +): { confirmationId: string } { + const id = generateId(); + + const timer = setTimeout(() => { + pending.delete(id); + }, AUTO_DENY_TIMEOUT_MS); + + pending.set(id, { + id, + action, + category, + description, + command, + timer, + }); + + return { confirmationId: id }; +} + +export function getAndRemovePending( + id: string +): { command: Record; action: string } | null { + const entry = pending.get(id); + if (!entry) return null; + + clearTimeout(entry.timer); + pending.delete(id); + return { command: entry.command, action: entry.action }; +} diff --git a/src/daemon.ts b/src/daemon.ts index 81243b0..ee2bf6f 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -5,7 +5,7 @@ import * as os from 'os'; import { BrowserManager } from './browser.js'; import { IOSManager } from './ios-manager.js'; import { parseCommand, serializeResponse, errorResponse } from './protocol.js'; -import { executeCommand } from './actions.js'; +import { executeCommand, initActionPolicy } from './actions.js'; import { executeIOSCommand } from './ios-actions.js'; import { StreamServer } from './stream-server.js'; import { @@ -333,6 +333,9 @@ export async function startDaemon(options?: { // Clean up expired state files on startup runCleanupExpiredStates(); + // Initialize action policy enforcement + initActionPolicy(); + // Determine provider from options or environment const provider = options?.provider ?? process.env.AGENT_BROWSER_PROVIDER; const isIOS = provider === 'ios'; @@ -630,9 +633,13 @@ export async function startDaemon(options?: { processQueue().catch((err) => { // Socket write failures during queue processing are non-fatal; // the client has likely disconnected. - console.warn('[warn] processQueue error:', err?.message ?? err); + // Only log err.message to avoid leaking sensitive fields (e.g. passwords) from command objects. + console.warn('[warn] processQueue error:', err?.message ?? String(err)); if (process.env.AGENT_BROWSER_DEBUG === '1') { - console.error('[DEBUG] processQueue error (full):', err); + console.error( + '[DEBUG] processQueue error stack:', + err?.stack ?? err?.message ?? String(err) + ); } }); }); diff --git a/src/domain-filter.test.ts b/src/domain-filter.test.ts new file mode 100644 index 0000000..36d327f --- /dev/null +++ b/src/domain-filter.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { isDomainAllowed, parseDomainList, buildWebSocketFilterScript } from './domain-filter.js'; + +describe('domain-filter', () => { + describe('isDomainAllowed', () => { + it('should match exact domains', () => { + expect(isDomainAllowed('example.com', ['example.com'])).toBe(true); + expect(isDomainAllowed('github.com', ['github.com'])).toBe(true); + }); + + it('should reject non-matching domains', () => { + expect(isDomainAllowed('evil.com', ['example.com'])).toBe(false); + expect(isDomainAllowed('notexample.com', ['example.com'])).toBe(false); + }); + + it('should match wildcard patterns', () => { + expect(isDomainAllowed('sub.example.com', ['*.example.com'])).toBe(true); + expect(isDomainAllowed('deep.sub.example.com', ['*.example.com'])).toBe(true); + }); + + it('should match bare domain against wildcard pattern', () => { + expect(isDomainAllowed('example.com', ['*.example.com'])).toBe(true); + }); + + it('should reject non-matching wildcard patterns', () => { + expect(isDomainAllowed('example.org', ['*.example.com'])).toBe(false); + expect(isDomainAllowed('evil.com', ['*.example.com'])).toBe(false); + }); + + it('should return false for empty allowlist', () => { + expect(isDomainAllowed('example.com', [])).toBe(false); + }); + + it('should match against multiple patterns', () => { + const patterns = ['example.com', '*.github.com', 'vercel.app']; + expect(isDomainAllowed('example.com', patterns)).toBe(true); + expect(isDomainAllowed('api.github.com', patterns)).toBe(true); + expect(isDomainAllowed('vercel.app', patterns)).toBe(true); + expect(isDomainAllowed('evil.com', patterns)).toBe(false); + }); + + it('should not partially match domain suffixes without wildcard', () => { + expect(isDomainAllowed('sub.example.com', ['example.com'])).toBe(false); + }); + }); + + describe('parseDomainList', () => { + it('should split comma-separated domains', () => { + expect(parseDomainList('a.com,b.com')).toEqual(['a.com', 'b.com']); + }); + + it('should trim whitespace', () => { + expect(parseDomainList(' a.com , b.com ')).toEqual(['a.com', 'b.com']); + }); + + it('should lowercase domains', () => { + expect(parseDomainList('Example.COM,GitHub.Com')).toEqual(['example.com', 'github.com']); + }); + + it('should filter empty entries', () => { + expect(parseDomainList('a.com,,b.com,')).toEqual(['a.com', 'b.com']); + }); + + it('should handle empty string', () => { + expect(parseDomainList('')).toEqual([]); + }); + + it('should preserve wildcard prefixes', () => { + expect(parseDomainList('*.example.com')).toEqual(['*.example.com']); + }); + }); + + describe('buildWebSocketFilterScript', () => { + it('should produce a valid JavaScript IIFE', () => { + const script = buildWebSocketFilterScript(['example.com', '*.github.com']); + expect(script).toContain('_allowedDomains'); + expect(script).toContain('"example.com"'); + expect(script).toContain('"*.github.com"'); + }); + + it('should embed the domain list as JSON', () => { + const script = buildWebSocketFilterScript(['a.com']); + expect(script).toContain('["a.com"]'); + }); + + it('should include WebSocket, EventSource, and sendBeacon patches', () => { + const script = buildWebSocketFilterScript(['a.com']); + expect(script).toContain('WebSocket'); + expect(script).toContain('EventSource'); + expect(script).toContain('SecurityError'); + expect(script).toContain('sendBeacon'); + }); + + it('should handle empty allowlist', () => { + const script = buildWebSocketFilterScript([]); + expect(script).toContain('[]'); + }); + + it('should include domain matching logic consistent with isDomainAllowed', () => { + const script = buildWebSocketFilterScript(['*.example.com']); + expect(script).toContain('_isDomainAllowed'); + expect(script).toContain('slice(1)'); + expect(script).toContain('slice(2)'); + }); + }); +}); diff --git a/src/domain-filter.ts b/src/domain-filter.ts new file mode 100644 index 0000000..19dbff8 --- /dev/null +++ b/src/domain-filter.ts @@ -0,0 +1,156 @@ +import type { BrowserContext, Route } from 'playwright-core'; + +/** + * Checks whether a hostname matches one of the allowed domain patterns. + * Patterns support exact match ("example.com") and wildcard prefix ("*.example.com"). + */ +export function isDomainAllowed(hostname: string, allowedDomains: string[]): boolean { + for (const pattern of allowedDomains) { + if (pattern.startsWith('*.')) { + const suffix = pattern.slice(1); // ".example.com" + if (hostname === pattern.slice(2) || hostname.endsWith(suffix)) { + return true; + } + } else if (hostname === pattern) { + return true; + } + } + return false; +} + +export function parseDomainList(raw: string): string[] { + return raw + .split(',') + .map((d) => d.trim().toLowerCase()) + .filter((d) => d.length > 0); +} + +/** + * Build the init script source that monkey-patches WebSocket, EventSource, + * and navigator.sendBeacon to block connections to non-allowed domains. + * Exported for testing. + */ +export function buildWebSocketFilterScript(allowedDomains: string[]): string { + const serialized = JSON.stringify(allowedDomains); + return `(function() { + var _allowedDomains = ${serialized}; + function _isDomainAllowed(hostname) { + hostname = hostname.toLowerCase(); + for (var i = 0; i < _allowedDomains.length; i++) { + var pattern = _allowedDomains[i]; + if (pattern.indexOf('*.') === 0) { + var suffix = pattern.slice(1); + if (hostname === pattern.slice(2) || hostname.slice(-suffix.length) === suffix) { + return true; + } + } else if (hostname === pattern) { + return true; + } + } + return false; + } + function _checkUrl(url) { + try { + var parsed = new URL(url); + return _isDomainAllowed(parsed.hostname); + } catch(e) { + return false; + } + } + if (typeof WebSocket !== 'undefined') { + var _OrigWS = WebSocket; + WebSocket = function(url, protocols) { + if (!_checkUrl(url)) { + throw new DOMException( + 'WebSocket connection to ' + url + ' blocked by domain allowlist', + 'SecurityError' + ); + } + if (protocols !== undefined) { + return new _OrigWS(url, protocols); + } + return new _OrigWS(url); + }; + WebSocket.prototype = _OrigWS.prototype; + WebSocket.CONNECTING = _OrigWS.CONNECTING; + WebSocket.OPEN = _OrigWS.OPEN; + WebSocket.CLOSING = _OrigWS.CLOSING; + WebSocket.CLOSED = _OrigWS.CLOSED; + } + if (typeof EventSource !== 'undefined') { + var _OrigES = EventSource; + EventSource = function(url, opts) { + if (!_checkUrl(url)) { + throw new DOMException( + 'EventSource connection to ' + url + ' blocked by domain allowlist', + 'SecurityError' + ); + } + return new _OrigES(url, opts); + }; + EventSource.prototype = _OrigES.prototype; + EventSource.CONNECTING = _OrigES.CONNECTING; + EventSource.OPEN = _OrigES.OPEN; + EventSource.CLOSED = _OrigES.CLOSED; + } + if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') { + var _origSendBeacon = navigator.sendBeacon.bind(navigator); + navigator.sendBeacon = function(url, data) { + if (!_checkUrl(url)) { + return false; + } + return _origSendBeacon(url, data); + }; + } +})();`; +} + +/** + * Installs a context-level route that enforces the domain allowlist. + * Both document navigations and sub-resource requests (scripts, images, fetch, etc.) + * to non-allowed domains are blocked, preventing data exfiltration. + * Non-http(s) schemes (data:, blob:, etc.) are allowed for sub-resources + * but blocked for document navigations. + * + * Also installs an init script that patches WebSocket, EventSource, and + * navigator.sendBeacon to block connections to non-allowed domains. This is + * a best-effort defense: if eval is permitted by action policy, page scripts + * could theoretically restore the originals. Denying the eval action + * category closes that loophole. + */ +export async function installDomainFilter( + context: BrowserContext, + allowedDomains: string[] +): Promise { + if (allowedDomains.length === 0) return; + + await context.addInitScript(buildWebSocketFilterScript(allowedDomains)); + + await context.route('**/*', async (route: Route) => { + const request = route.request(); + const urlStr = request.url(); + + if (!urlStr.startsWith('http://') && !urlStr.startsWith('https://')) { + if (request.resourceType() === 'document') { + await route.abort('blockedbyclient'); + } else { + await route.continue(); + } + return; + } + + let hostname: string; + try { + hostname = new URL(urlStr).hostname.toLowerCase(); + } catch { + await route.abort('blockedbyclient'); + return; + } + + if (isDomainAllowed(hostname, allowedDomains)) { + await route.continue(); + } else { + await route.abort('blockedbyclient'); + } + }); +} diff --git a/src/encryption.ts b/src/encryption.ts index 577ee15..3b366a0 100644 --- a/src/encryption.ts +++ b/src/encryption.ts @@ -3,6 +3,10 @@ */ import * as crypto from 'crypto'; +import { execSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import os from 'node:os'; // ============================================ // Constants @@ -10,6 +14,7 @@ import * as crypto from 'crypto'; export const ENCRYPTION_ALGORITHM = 'aes-256-gcm'; export const ENCRYPTION_KEY_ENV = 'AGENT_BROWSER_ENCRYPTION_KEY'; export const IV_LENGTH = 12; // 96 bits for GCM +const KEY_FILE_NAME = '.encryption-key'; /** * Encrypted payload structure. @@ -22,27 +27,114 @@ export interface EncryptedPayload { data: string; // Base64 encoded ciphertext } +export function getKeyFilePath(): string { + return join(os.homedir(), '.agent-browser', KEY_FILE_NAME); +} + /** - * Get encryption key from environment variable. + * Restrict file permissions to the current user only. + * On Unix, the caller should use `mode: 0o600` when writing. This function + * handles Windows where Node's mode parameter is ignored. + */ +export function restrictFilePermissions(filePath: string): void { + if (os.platform() !== 'win32') return; + try { + execSync(`icacls "${filePath}" /inheritance:r /grant:r "%USERNAME%:F"`, { + stdio: 'ignore', + windowsHide: true, + }); + } catch { + // Best-effort; may fail in some environments (containers, restricted shells) + } +} + +/** + * Restrict directory permissions to the current user only. + * On Unix, the caller should use `mode: 0o700` when creating. This function + * handles Windows where Node's mode parameter is ignored. + */ +export function restrictDirPermissions(dirPath: string): void { + if (os.platform() !== 'win32') return; + try { + execSync(`icacls "${dirPath}" /inheritance:r /grant:r "%USERNAME%:(OI)(CI)F"`, { + stdio: 'ignore', + windowsHide: true, + }); + } catch { + // Best-effort + } +} + +function parseKeyHex(keyHex: string): Buffer | null { + if (!/^[a-fA-F0-9]{64}$/.test(keyHex.trim())) return null; + return Buffer.from(keyHex.trim(), 'hex'); +} + +/** + * Get encryption key from environment variable or key file. * The key should be a 32-byte (256-bit) hex-encoded string (64 characters). * Generate with: openssl rand -hex 32 * - * @returns Buffer containing the key, or null if not set/invalid + * Checks (in order): + * 1. AGENT_BROWSER_ENCRYPTION_KEY env var + * 2. ~/.agent-browser/.encryption-key file + * + * @returns Buffer containing the key, or null if not available */ export function getEncryptionKey(): Buffer | null { const keyHex = process.env[ENCRYPTION_KEY_ENV]; - if (!keyHex) return null; - - // Key should be 64 hex chars = 32 bytes = 256 bits - if (!/^[a-fA-F0-9]{64}$/.test(keyHex)) { - console.warn( - `Warning: ${ENCRYPTION_KEY_ENV} should be a 64-character hex string (256 bits). ` + - `Generate one with: openssl rand -hex 32` - ); - return null; + if (keyHex) { + const key = parseKeyHex(keyHex); + if (!key) { + console.warn( + `Warning: ${ENCRYPTION_KEY_ENV} should be a 64-character hex string (256 bits). ` + + `Generate one with: openssl rand -hex 32` + ); + return null; + } + return key; } - return Buffer.from(keyHex, 'hex'); + const keyFilePath = getKeyFilePath(); + if (existsSync(keyFilePath)) { + try { + const fileHex = readFileSync(keyFilePath, 'utf-8'); + return parseKeyHex(fileHex); + } catch { + return null; + } + } + + return null; +} + +/** + * Ensure an encryption key is available, auto-generating one if needed. + * On first call without an existing key, generates a random 256-bit key + * and writes it to ~/.agent-browser/.encryption-key (mode 0600). + */ +export function ensureEncryptionKey(): Buffer { + const existing = getEncryptionKey(); + if (existing) return existing; + + const key = crypto.randomBytes(32); + const keyHex = key.toString('hex'); + + const dir = join(os.homedir(), '.agent-browser'); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + restrictDirPermissions(dir); + } + + const keyFilePath = getKeyFilePath(); + writeFileSync(keyFilePath, keyHex + '\n', { mode: 0o600 }); + restrictFilePermissions(keyFilePath); + + console.error( + `[agent-browser] Auto-generated encryption key at ${keyFilePath} -- back up this file or set ${ENCRYPTION_KEY_ENV}` + ); + + return key; } /** diff --git a/src/protocol.ts b/src/protocol.ts index 3aa2f35..0eb9cf0 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -52,6 +52,9 @@ const launchSchema = baseCommandSchema.extend({ colorScheme: z.enum(['light', 'dark', 'no-preference']).optional(), downloadPath: 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({ @@ -874,6 +877,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, @@ -1011,6 +1061,13 @@ const commandSchema = z.discriminatedUnion('action', [ diffSnapshotSchema, diffScreenshotSchema, diffUrlSchema, + confirmSchema, + denySchema, + authSaveSchema, + authLoginSchema, + authListSchema, + authDeleteSchema, + authShowSchema, ]); // Parse result type diff --git a/src/types.ts b/src/types.ts index 620ffb4..19d27b2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,6 +41,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; } @@ -1033,7 +1036,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 { @@ -1105,14 +1155,39 @@ export interface ScreenshotData { export interface SnapshotData { snapshot: string; + refs?: Record; + origin?: string; } export interface EvaluateData { result: unknown; + origin?: string; } export interface ContentData { html: string; + origin?: string; +} + +export interface TextData { + text: string | null; + origin?: string; +} + +export interface AttributeData { + attribute: string; + value: string | null; + origin?: string; +} + +export interface ValueData { + value: string; + origin?: string; +} + +export interface ConsoleData { + messages: Array<{ type: string; text: string }>; + origin?: string; } export interface TabInfo {