add security hardening features (#543)

* add security hardening features

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

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* docs
This commit is contained in:
Chris Tate
2026-02-25 15:33:20 -06:00
committed by GitHub
parent c0e2b80f8c
commit bc1e917e87
28 changed files with 3444 additions and 476 deletions
+133
View File
@@ -92,6 +92,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
match cmd {
// === Navigation ===
// Maps to "navigate" action in protocol; reflected in ACTION_CATEGORIES in action-policy.ts
"open" | "goto" | "navigate" => {
let url = rest.first().ok_or_else(|| ParseError::MissingArguments {
context: cmd.to_string(),
@@ -561,6 +562,131 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
// === Close ===
"close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })),
// === Authentication Vault ===
"auth" => {
let sub = rest.first().map(|s| s.as_ref());
match sub {
Some("save") => {
let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "auth save".to_string(),
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass>",
})?;
let mut url = None;
let mut username = None;
let mut password = None;
let mut password_stdin = false;
let mut username_selector = None;
let mut password_selector = None;
let mut submit_selector = None;
let mut j = 2;
while j < rest.len() {
match rest[j].as_ref() {
"--url" => { url = rest.get(j + 1).cloned(); j += 1; }
"--username" => { username = rest.get(j + 1).cloned(); j += 1; }
"--password" => { password = rest.get(j + 1).cloned(); j += 1; }
"--password-stdin" => { password_stdin = true; }
"--username-selector" => { username_selector = rest.get(j + 1).cloned(); j += 1; }
"--password-selector" => { password_selector = rest.get(j + 1).cloned(); j += 1; }
"--submit-selector" => { submit_selector = rest.get(j + 1).cloned(); j += 1; }
other => {
if other.starts_with("--") {
return Err(ParseError::InvalidValue {
message: format!("unknown flag '{}' for auth save", other),
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass>",
});
}
}
}
j += 1;
}
let url_val = url.ok_or_else(|| ParseError::MissingArguments {
context: "auth save".to_string(),
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass> [--password-stdin]",
})?;
let user_val = username.ok_or_else(|| ParseError::MissingArguments {
context: "auth save".to_string(),
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass> [--password-stdin]",
})?;
if !password_stdin && password.is_none() {
return Err(ParseError::MissingArguments {
context: "auth save".to_string(),
usage: "agent-browser auth save <name> --url <url> --username <user> --password <pass> [--password-stdin]",
});
}
let mut cmd = json!({
"id": id,
"action": "auth_save",
"name": name,
"url": url_val,
"username": user_val,
});
if password_stdin {
cmd["passwordStdin"] = json!(true);
}
if let Some(pass_val) = password {
cmd["password"] = json!(pass_val);
}
if let Some(us) = username_selector {
cmd["usernameSelector"] = json!(us);
}
if let Some(ps) = password_selector {
cmd["passwordSelector"] = json!(ps);
}
if let Some(ss) = submit_selector {
cmd["submitSelector"] = json!(ss);
}
Ok(cmd)
}
Some("login") => {
let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "auth login".to_string(),
usage: "agent-browser auth login <name>",
})?;
Ok(json!({ "id": id, "action": "auth_login", "name": name }))
}
Some("list") => Ok(json!({ "id": id, "action": "auth_list" })),
Some("delete") | Some("remove") => {
let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "auth delete".to_string(),
usage: "agent-browser auth delete <name>",
})?;
Ok(json!({ "id": id, "action": "auth_delete", "name": name }))
}
Some("show") => {
let name = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "auth show".to_string(),
usage: "agent-browser auth show <name>",
})?;
Ok(json!({ "id": id, "action": "auth_show", "name": name }))
}
_ => Err(ParseError::UnknownSubcommand {
subcommand: sub.unwrap_or("(none)").to_string(),
valid_options: &["save", "login", "list", "delete", "show"],
}),
}
}
// === Action Confirmation ===
"confirm" => {
let cid = rest.first().ok_or_else(|| ParseError::MissingArguments {
context: "confirm".to_string(),
usage: "agent-browser confirm <confirmation-id>",
})?;
Ok(json!({ "id": id, "action": "confirm", "confirmationId": cid }))
}
"deny" => {
let cid = rest.first().ok_or_else(|| ParseError::MissingArguments {
context: "deny".to_string(),
usage: "agent-browser deny <confirmation-id>",
})?;
Ok(json!({ "id": id, "action": "deny", "confirmationId": cid }))
}
// === Connect (CDP) ===
"connect" => {
let endpoint = rest.first().ok_or_else(|| ParseError::MissingArguments {
@@ -1936,6 +2062,13 @@ mod tests {
annotate: false,
color_scheme: None,
download_path: None,
content_boundaries: false,
max_output: None,
allowed_domains: None,
action_policy: None,
confirm_actions: None,
confirm_interactive: false,
}
}
+90 -142
View File
@@ -203,24 +203,94 @@ pub struct DaemonResult {
pub already_running: bool,
}
#[allow(clippy::too_many_arguments)]
/// Options forwarded to the daemon process as environment variables.
/// Note: `confirm_interactive` is intentionally absent -- it is a CLI-side
/// UX concern (prompting the user on stdin) and not a daemon configuration.
/// The daemon only needs `confirm_actions` to gate action categories.
pub struct DaemonOptions<'a> {
pub headed: bool,
pub executable_path: Option<&'a str>,
pub extensions: &'a [String],
pub args: Option<&'a str>,
pub user_agent: Option<&'a str>,
pub proxy: Option<&'a str>,
pub proxy_bypass: Option<&'a str>,
pub ignore_https_errors: bool,
pub allow_file_access: bool,
pub profile: Option<&'a str>,
pub state: Option<&'a str>,
pub provider: Option<&'a str>,
pub device: Option<&'a str>,
pub session_name: Option<&'a str>,
pub download_path: Option<&'a str>,
pub allowed_domains: Option<&'a [String]>,
pub action_policy: Option<&'a str>,
pub confirm_actions: Option<&'a str>,
}
fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
cmd.env("AGENT_BROWSER_DAEMON", "1")
.env("AGENT_BROWSER_SESSION", session);
if opts.headed {
cmd.env("AGENT_BROWSER_HEADED", "1");
}
if let Some(path) = opts.executable_path {
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
}
if !opts.extensions.is_empty() {
cmd.env("AGENT_BROWSER_EXTENSIONS", opts.extensions.join(","));
}
if let Some(a) = opts.args {
cmd.env("AGENT_BROWSER_ARGS", a);
}
if let Some(ua) = opts.user_agent {
cmd.env("AGENT_BROWSER_USER_AGENT", ua);
}
if let Some(p) = opts.proxy {
cmd.env("AGENT_BROWSER_PROXY", p);
}
if let Some(pb) = opts.proxy_bypass {
cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb);
}
if opts.ignore_https_errors {
cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1");
}
if opts.allow_file_access {
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
}
if let Some(prof) = opts.profile {
cmd.env("AGENT_BROWSER_PROFILE", prof);
}
if let Some(st) = opts.state {
cmd.env("AGENT_BROWSER_STATE", st);
}
if let Some(p) = opts.provider {
cmd.env("AGENT_BROWSER_PROVIDER", p);
}
if let Some(d) = opts.device {
cmd.env("AGENT_BROWSER_IOS_DEVICE", d);
}
if let Some(sn) = opts.session_name {
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
}
if let Some(dp) = opts.download_path {
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
}
if let Some(ad) = opts.allowed_domains {
cmd.env("AGENT_BROWSER_ALLOWED_DOMAINS", ad.join(","));
}
if let Some(ap) = opts.action_policy {
cmd.env("AGENT_BROWSER_ACTION_POLICY", ap);
}
if let Some(ca) = opts.confirm_actions {
cmd.env("AGENT_BROWSER_CONFIRM_ACTIONS", ca);
}
}
pub fn ensure_daemon(
session: &str,
headed: bool,
executable_path: Option<&str>,
extensions: &[String],
args: Option<&str>,
user_agent: Option<&str>,
proxy: Option<&str>,
proxy_bypass: Option<&str>,
ignore_https_errors: bool,
allow_file_access: bool,
profile: Option<&str>,
state: Option<&str>,
provider: Option<&str>,
device: Option<&str>,
session_name: Option<&str>,
download_path: Option<&str>,
opts: &DaemonOptions,
) -> Result<DaemonResult, String> {
// Check if daemon is running AND responsive
if is_daemon_running(session) && daemon_ready(session) {
@@ -305,69 +375,8 @@ pub fn ensure_daemon(
use std::os::unix::process::CommandExt;
let mut cmd = Command::new("node");
cmd.arg(daemon_path)
.env("AGENT_BROWSER_DAEMON", "1")
.env("AGENT_BROWSER_SESSION", session);
if headed {
cmd.env("AGENT_BROWSER_HEADED", "1");
}
if let Some(path) = executable_path {
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
}
if !extensions.is_empty() {
cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(","));
}
if let Some(a) = args {
cmd.env("AGENT_BROWSER_ARGS", a);
}
if let Some(ua) = user_agent {
cmd.env("AGENT_BROWSER_USER_AGENT", ua);
}
if let Some(p) = proxy {
cmd.env("AGENT_BROWSER_PROXY", p);
}
if let Some(pb) = proxy_bypass {
cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb);
}
if ignore_https_errors {
cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1");
}
if allow_file_access {
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
}
if let Some(prof) = profile {
cmd.env("AGENT_BROWSER_PROFILE", prof);
}
if let Some(st) = state {
cmd.env("AGENT_BROWSER_STATE", st);
}
if let Some(p) = provider {
cmd.env("AGENT_BROWSER_PROVIDER", p);
}
if let Some(d) = device {
cmd.env("AGENT_BROWSER_IOS_DEVICE", d);
}
if let Some(sn) = session_name {
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
}
if let Some(dp) = download_path {
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
}
cmd.arg(daemon_path);
apply_daemon_env(&mut cmd, session, opts);
// Create new process group and session to fully detach
unsafe {
@@ -392,69 +401,8 @@ pub fn ensure_daemon(
// On Windows, call node directly. Command::new handles PATH resolution (node.exe or node.cmd)
// and automatically quotes arguments containing spaces.
let mut cmd = Command::new("node");
cmd.arg(daemon_path)
.env("AGENT_BROWSER_DAEMON", "1")
.env("AGENT_BROWSER_SESSION", session);
if headed {
cmd.env("AGENT_BROWSER_HEADED", "1");
}
if let Some(path) = executable_path {
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
}
if !extensions.is_empty() {
cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(","));
}
if let Some(a) = args {
cmd.env("AGENT_BROWSER_ARGS", a);
}
if let Some(ua) = user_agent {
cmd.env("AGENT_BROWSER_USER_AGENT", ua);
}
if let Some(p) = proxy {
cmd.env("AGENT_BROWSER_PROXY", p);
}
if let Some(pb) = proxy_bypass {
cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb);
}
if ignore_https_errors {
cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1");
}
if allow_file_access {
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
}
if let Some(prof) = profile {
cmd.env("AGENT_BROWSER_PROFILE", prof);
}
if let Some(st) = state {
cmd.env("AGENT_BROWSER_STATE", st);
}
if let Some(p) = provider {
cmd.env("AGENT_BROWSER_PROVIDER", p);
}
if let Some(d) = device {
cmd.env("AGENT_BROWSER_IOS_DEVICE", d);
}
if let Some(sn) = session_name {
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
}
if let Some(dp) = download_path {
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
}
cmd.arg(daemon_path);
apply_daemon_env(&mut cmd, session, opts);
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
+85
View File
@@ -35,6 +35,12 @@ pub struct Config {
pub annotate: Option<bool>,
pub color_scheme: Option<String>,
pub download_path: Option<String>,
pub content_boundaries: Option<bool>,
pub max_output: Option<usize>,
pub allowed_domains: Option<Vec<String>>,
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: Option<bool>,
}
impl Config {
@@ -70,6 +76,12 @@ impl Config {
annotate: other.annotate.or(self.annotate),
color_scheme: other.color_scheme.or(self.color_scheme),
download_path: other.download_path.or(self.download_path),
content_boundaries: other.content_boundaries.or(self.content_boundaries),
max_output: other.max_output.or(self.max_output),
allowed_domains: other.allowed_domains.or(self.allowed_domains),
action_policy: other.action_policy.or(self.action_policy),
confirm_actions: other.confirm_actions.or(self.confirm_actions),
confirm_interactive: other.confirm_interactive.or(self.confirm_interactive),
}
}
}
@@ -116,6 +128,11 @@ fn parse_bool_arg(args: &[String], i: usize) -> (bool, bool) {
/// Extract --config <path> from args before full flag parsing.
/// Returns `Some(Some(path))` if --config <path> found, `Some(None)` if --config
/// was the last arg with no value, `None` if --config not present.
///
/// Only flags that consume a following argument need to be listed here.
/// Boolean flags (--content-boundaries, --confirm-interactive, etc.) are
/// intentionally absent -- they don't take a value, so they can't cause
/// the next argument to be mis-consumed.
fn extract_config_path(args: &[String]) -> Option<Option<String>> {
const FLAGS_WITH_VALUE: &[&str] = &[
"--session",
@@ -135,6 +152,10 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
"--session-name",
"--color-scheme",
"--download-path",
"--max-output",
"--allowed-domains",
"--action-policy",
"--confirm-actions",
];
let mut i = 0;
while i < args.len() {
@@ -207,6 +228,12 @@ pub struct Flags {
pub annotate: bool,
pub color_scheme: Option<String>,
pub download_path: Option<String>,
pub content_boundaries: bool,
pub max_output: Option<usize>,
pub allowed_domains: Option<Vec<String>>,
pub action_policy: Option<String>,
pub confirm_actions: Option<String>,
pub confirm_interactive: bool,
// Track which launch-time options were explicitly passed via CLI
// (as opposed to being set only via environment variables)
@@ -292,6 +319,20 @@ pub fn parse_flags(args: &[String]) -> Flags {
.or(config.color_scheme),
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok()
.or(config.download_path),
content_boundaries: env_var_is_truthy("AGENT_BROWSER_CONTENT_BOUNDARIES")
|| config.content_boundaries.unwrap_or(false),
max_output: env::var("AGENT_BROWSER_MAX_OUTPUT").ok()
.and_then(|s| s.parse().ok())
.or(config.max_output),
allowed_domains: env::var("AGENT_BROWSER_ALLOWED_DOMAINS").ok()
.map(|s| s.split(',').map(|d| d.trim().to_lowercase()).filter(|d| !d.is_empty()).collect())
.or(config.allowed_domains),
action_policy: env::var("AGENT_BROWSER_ACTION_POLICY").ok()
.or(config.action_policy),
confirm_actions: env::var("AGENT_BROWSER_CONFIRM_ACTIONS").ok()
.or(config.confirm_actions),
confirm_interactive: env_var_is_truthy("AGENT_BROWSER_CONFIRM_INTERACTIVE")
|| config.confirm_interactive.unwrap_or(false),
cli_executable_path: false,
cli_extensions: false,
cli_profile: false,
@@ -455,6 +496,44 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--content-boundaries" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.content_boundaries = val;
if consumed { i += 1; }
}
"--max-output" => {
if let Some(s) = args.get(i + 1) {
if let Ok(n) = s.parse::<usize>() {
flags.max_output = Some(n);
}
i += 1;
}
}
"--allowed-domains" => {
if let Some(s) = args.get(i + 1) {
flags.allowed_domains = Some(
s.split(',').map(|d| d.trim().to_lowercase()).filter(|d| !d.is_empty()).collect()
);
i += 1;
}
}
"--action-policy" => {
if let Some(s) = args.get(i + 1) {
flags.action_policy = Some(s.clone());
i += 1;
}
}
"--confirm-actions" => {
if let Some(s) = args.get(i + 1) {
flags.confirm_actions = Some(s.clone());
i += 1;
}
}
"--confirm-interactive" => {
let (val, consumed) = parse_bool_arg(args, i);
flags.confirm_interactive = val;
if consumed { i += 1; }
}
"--config" => {
// Already handled by load_config(); skip the value
i += 1;
@@ -480,6 +559,8 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--allow-file-access",
"--auto-connect",
"--annotate",
"--content-boundaries",
"--confirm-interactive",
];
// Global flags that always take a value (need to skip the next arg too)
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[
@@ -500,6 +581,10 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--session-name",
"--color-scheme",
"--download-path",
"--max-output",
"--allowed-domains",
"--action-policy",
"--confirm-actions",
"--config",
];
+208 -22
View File
@@ -17,10 +17,108 @@ use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
use commands::{gen_id, parse_command, ParseError};
use connection::{ensure_daemon, get_socket_dir, send_command};
use connection::{ensure_daemon, get_socket_dir, send_command, DaemonOptions};
use flags::{clean_args, parse_flags};
use install::run_install;
use output::{print_command_help, print_help, print_response, print_version};
use output::{print_command_help, print_help, print_response_with_opts, print_version, OutputOptions};
use std::path::PathBuf;
use std::process::Command as ProcessCommand;
/// Run a local auth command (auth_save/list/show/delete) via node auth-cli.js.
/// These commands don't need a browser, so we handle them directly to avoid
/// sending passwords through the daemon's Unix socket channel.
fn run_auth_cli(cmd: &serde_json::Value, json_mode: bool) -> ! {
let exe_path = env::current_exe().unwrap_or_default();
let exe_path = exe_path.canonicalize().unwrap_or(exe_path);
let exe_dir = exe_path.parent().unwrap_or(std::path::Path::new("."));
let mut script_paths = vec![
exe_dir.join("auth-cli.js"),
exe_dir.join("../dist/auth-cli.js"),
PathBuf::from("dist/auth-cli.js"),
];
if let Ok(home) = env::var("AGENT_BROWSER_HOME") {
let home_path = PathBuf::from(&home);
script_paths.insert(0, home_path.join("dist/auth-cli.js"));
script_paths.insert(1, home_path.join("auth-cli.js"));
}
let script_path = match script_paths.iter().find(|p| p.exists()) {
Some(p) => p.clone(),
None => {
if json_mode {
println!(r#"{{"success":false,"error":"auth-cli.js not found"}}"#);
} else {
eprintln!(
"{} auth-cli.js not found. Set AGENT_BROWSER_HOME or run from project directory.",
color::error_indicator()
);
}
exit(1);
}
};
let cmd_json = serde_json::to_string(cmd).unwrap_or_default();
match ProcessCommand::new("node")
.arg(&script_path)
.arg(&cmd_json)
.output()
{
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.is_empty() {
eprint!("{}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stdout = stdout.trim();
if stdout.is_empty() {
if json_mode {
println!(r#"{{"success":false,"error":"No response from auth-cli"}}"#);
} else {
eprintln!("{} No response from auth-cli", color::error_indicator());
}
exit(1);
}
if json_mode {
println!("{}", stdout);
} else {
// Parse the JSON response and use the standard output formatter
match serde_json::from_str::<connection::Response>(stdout) {
Ok(resp) => {
let action = cmd.get("action").and_then(|v| v.as_str());
let opts = OutputOptions {
json: false,
content_boundaries: false,
max_output: None,
};
print_response_with_opts(&resp, action, &opts);
if !resp.success {
exit(1);
}
}
Err(_) => {
println!("{}", stdout);
}
}
}
exit(output.status.code().unwrap_or(0));
}
Err(e) => {
if json_mode {
println!(r#"{{"success":false,"error":"Failed to run auth-cli: {}"}}"#, e);
} else {
eprintln!("{} Failed to run auth-cli: {}", color::error_indicator(), e);
}
exit(1);
}
}
}
fn parse_proxy(proxy_str: &str) -> serde_json::Value {
let Some(protocol_end) = proxy_str.find("://") else {
@@ -171,7 +269,7 @@ fn main() {
return;
}
let cmd = match parse_command(&clean, &flags) {
let mut cmd = match parse_command(&clean, &flags) {
Ok(c) => c,
Err(e) => {
if flags.json {
@@ -194,6 +292,38 @@ fn main() {
}
};
// Handle --password-stdin for auth save
if cmd.get("action").and_then(|v| v.as_str()) == Some("auth_save") {
if cmd.get("password").is_some() {
eprintln!(
"{} Passwords on the command line may be visible in process listings and shell history. Use --password-stdin instead.",
color::warning_indicator()
);
}
if cmd.get("passwordStdin").and_then(|v| v.as_bool()).unwrap_or(false) {
let mut pass = String::new();
if std::io::stdin().read_line(&mut pass).is_err() || pass.is_empty() {
eprintln!("{} Failed to read password from stdin", color::error_indicator());
exit(1);
}
let pass = pass.trim_end_matches('\n').trim_end_matches('\r');
if pass.is_empty() {
eprintln!("{} Password from stdin is empty", color::error_indicator());
exit(1);
}
cmd["password"] = json!(pass);
cmd.as_object_mut().unwrap().remove("passwordStdin");
}
}
// Handle local auth commands without starting the daemon.
// These don't need a browser, so we avoid sending passwords through the socket.
if let Some(action) = cmd.get("action").and_then(|v| v.as_str()) {
if matches!(action, "auth_save" | "auth_list" | "auth_show" | "auth_delete") {
run_auth_cli(&cmd, flags.json);
}
}
// Validate session name before starting daemon
if let Some(ref name) = flags.session_name {
if !validation::is_valid_session_name(name) {
@@ -210,24 +340,27 @@ fn main() {
}
}
let daemon_result = match ensure_daemon(
&flags.session,
flags.headed,
flags.executable_path.as_deref(),
&flags.extensions,
flags.args.as_deref(),
flags.user_agent.as_deref(),
flags.proxy.as_deref(),
flags.proxy_bypass.as_deref(),
flags.ignore_https_errors,
flags.allow_file_access,
flags.profile.as_deref(),
flags.state.as_deref(),
flags.provider.as_deref(),
flags.device.as_deref(),
flags.session_name.as_deref(),
flags.download_path.as_deref(),
) {
let daemon_opts = DaemonOptions {
headed: flags.headed,
executable_path: flags.executable_path.as_deref(),
extensions: &flags.extensions,
args: flags.args.as_deref(),
user_agent: flags.user_agent.as_deref(),
proxy: flags.proxy.as_deref(),
proxy_bypass: flags.proxy_bypass.as_deref(),
ignore_https_errors: flags.ignore_https_errors,
allow_file_access: flags.allow_file_access,
profile: flags.profile.as_deref(),
state: flags.state.as_deref(),
provider: flags.provider.as_deref(),
device: flags.device.as_deref(),
session_name: flags.session_name.as_deref(),
download_path: flags.download_path.as_deref(),
allowed_domains: flags.allowed_domains.as_deref(),
action_policy: flags.action_policy.as_deref(),
confirm_actions: flags.confirm_actions.as_deref(),
};
let daemon_result = match ensure_daemon(&flags.session, &daemon_opts) {
Ok(result) => result,
Err(e) => {
if flags.json {
@@ -588,6 +721,10 @@ fn main() {
launch_cmd["downloadPath"] = json!(dp);
}
if let Some(ref domains) = flags.allowed_domains {
launch_cmd["allowedDomains"] = json!(domains);
}
match send_command(launch_cmd, &flags.session) {
Ok(resp) if !resp.success => {
// Launch command failed (e.g., invalid state file, profile error)
@@ -619,12 +756,61 @@ fn main() {
}
}
let output_opts = OutputOptions {
json: flags.json,
content_boundaries: flags.content_boundaries,
max_output: flags.max_output,
};
match send_command(cmd.clone(), &flags.session) {
Ok(resp) => {
let success = resp.success;
// Handle interactive confirmation
if flags.confirm_interactive {
if let Some(data) = &resp.data {
if data.get("confirmation_required").and_then(|v| v.as_bool()).unwrap_or(false) {
let desc = data.get("description").and_then(|v| v.as_str()).unwrap_or("unknown action");
let category = data.get("category").and_then(|v| v.as_str()).unwrap_or("");
let cid = data.get("confirmation_id").and_then(|v| v.as_str()).unwrap_or("");
eprintln!("[agent-browser] Action requires confirmation:");
eprintln!(" {}: {}", category, desc);
eprint!(" Allow? [y/N]: ");
let mut input = String::new();
let approved = if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
std::io::stdin().read_line(&mut input).is_ok()
&& matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
} else {
false
};
let confirm_cmd = if approved {
json!({ "id": gen_id(), "action": "confirm", "confirmationId": cid })
} else {
json!({ "id": gen_id(), "action": "deny", "confirmationId": cid })
};
match send_command(confirm_cmd, &flags.session) {
Ok(r) => {
if !approved {
eprintln!("{} Action denied", color::error_indicator());
exit(1);
}
print_response_with_opts(&r, None, &output_opts);
}
Err(e) => {
eprintln!("{} {}", color::error_indicator(), e);
exit(1);
}
}
return;
}
}
}
// Extract action for context-specific output handling
let action = cmd.get("action").and_then(|v| v.as_str());
print_response(&resp, flags.json, action);
print_response_with_opts(&resp, action, &output_opts);
if !success {
exit(1);
}
+262 -14
View File
@@ -1,9 +1,83 @@
use std::sync::OnceLock;
use crate::color;
use crate::connection::Response;
pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
if json_mode {
println!("{}", serde_json::to_string(resp).unwrap_or_default());
static BOUNDARY_NONCE: OnceLock<String> = OnceLock::new();
/// Per-process nonce for content boundary markers. Uses a CSPRNG (getrandom) so
/// that untrusted page content cannot predict or spoof the boundary delimiter.
/// Process ID or timestamps would be insufficient since pages can read those.
fn get_boundary_nonce() -> &'static str {
BOUNDARY_NONCE.get_or_init(|| {
let mut buf = [0u8; 16];
getrandom::getrandom(&mut buf).expect("failed to generate random nonce");
buf.iter().map(|b| format!("{:02x}", b)).collect()
})
}
#[derive(Default)]
pub struct OutputOptions {
pub json: bool,
pub content_boundaries: bool,
pub max_output: Option<usize>,
}
fn truncate_if_needed(content: &str, max: Option<usize>) -> String {
let Some(limit) = max else {
return content.to_string();
};
// Fast path: byte length is a lower bound on char count, so if the
// byte length is within the limit the char count must be too.
if content.len() <= limit {
return content.to_string();
}
// Find the byte offset of the limit-th character.
match content.char_indices().nth(limit).map(|(i, _)| i) {
Some(byte_offset) => {
let total_chars = content.chars().count();
format!(
"{}\n[truncated: showing {} of {} chars. Use --max-output to adjust]",
&content[..byte_offset], limit, total_chars
)
}
// Content has fewer than `limit` chars despite more bytes
None => content.to_string(),
}
}
fn print_with_boundaries(content: &str, origin: Option<&str>, opts: &OutputOptions) {
let content = truncate_if_needed(content, opts.max_output);
if opts.content_boundaries {
let origin_str = origin.unwrap_or("unknown");
let nonce = get_boundary_nonce();
println!("--- AGENT_BROWSER_PAGE_CONTENT nonce={} origin={} ---", nonce, origin_str);
println!("{}", content);
println!("--- END_AGENT_BROWSER_PAGE_CONTENT nonce={} ---", nonce);
} else {
println!("{}", content);
}
}
pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &OutputOptions) {
if opts.json {
if opts.content_boundaries {
let mut json_val = serde_json::to_value(resp).unwrap_or_default();
if let Some(obj) = json_val.as_object_mut() {
let nonce = get_boundary_nonce();
let origin = obj.get("data")
.and_then(|d| d.get("origin"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
obj.insert("_boundary".to_string(), serde_json::json!({
"nonce": nonce,
"origin": origin,
}));
}
println!("{}", serde_json::to_string(&json_val).unwrap_or_default());
} else {
println!("{}", serde_json::to_string(resp).unwrap_or_default());
}
return;
}
@@ -56,9 +130,10 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
_ => {}
}
}
let origin = data.get("origin").and_then(|v| v.as_str());
// Snapshot
if let Some(snapshot) = data.get("snapshot").and_then(|v| v.as_str()) {
println!("{}", snapshot);
print_with_boundaries(snapshot, origin, opts);
return;
}
// Title
@@ -68,12 +143,12 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
}
// Text
if let Some(text) = data.get("text").and_then(|v| v.as_str()) {
println!("{}", text);
print_with_boundaries(text, origin, opts);
return;
}
// HTML
if let Some(html) = data.get("html").and_then(|v| v.as_str()) {
println!("{}", html);
print_with_boundaries(html, origin, opts);
return;
}
// Value
@@ -101,10 +176,8 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
}
// Eval result
if let Some(result) = data.get("result") {
println!(
"{}",
serde_json::to_string_pretty(result).unwrap_or_default()
);
let formatted = serde_json::to_string_pretty(result).unwrap_or_default();
print_with_boundaries(&formatted, origin, opts);
return;
}
// iOS Devices
@@ -191,10 +264,23 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
}
// Console logs
if let Some(logs) = data.get("messages").and_then(|v| v.as_array()) {
for log in logs {
let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log");
let text = log.get("text").and_then(|v| v.as_str()).unwrap_or("");
println!("{} {}", color::console_level_prefix(level), text);
if opts.content_boundaries {
let mut console_output = String::new();
for log in logs {
let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log");
let text = log.get("text").and_then(|v| v.as_str()).unwrap_or("");
console_output.push_str(&format!("{} {}\n", color::console_level_prefix(level), text));
}
if console_output.ends_with('\n') {
console_output.pop();
}
print_with_boundaries(&console_output, origin, opts);
} else {
for log in logs {
let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log");
let text = log.get("text").and_then(|v| v.as_str()).unwrap_or("");
println!("{} {}", color::console_level_prefix(level), text);
}
}
return;
}
@@ -548,6 +634,87 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
println!("{}", note);
return;
}
// Auth list
if let Some(profiles) = data.get("profiles").and_then(|v| v.as_array()) {
if profiles.is_empty() {
println!("{}", color::dim("No auth profiles saved"));
} else {
println!("{}", color::bold("Auth profiles:"));
for p in profiles {
let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("");
let url = p.get("url").and_then(|v| v.as_str()).unwrap_or("");
let user = p.get("username").and_then(|v| v.as_str()).unwrap_or("");
println!(" {} {} {}", color::green(name), color::dim(user), color::dim(url));
}
}
return;
}
// Auth show
if let Some(profile) = data.get("profile").and_then(|v| v.as_object()) {
let name = profile.get("name").and_then(|v| v.as_str()).unwrap_or("");
let url = profile.get("url").and_then(|v| v.as_str()).unwrap_or("");
let user = profile.get("username").and_then(|v| v.as_str()).unwrap_or("");
let created = profile.get("createdAt").and_then(|v| v.as_str()).unwrap_or("");
let last_login = profile.get("lastLoginAt").and_then(|v| v.as_str());
println!("Name: {}", name);
println!("URL: {}", url);
println!("Username: {}", user);
println!("Created: {}", created);
if let Some(ll) = last_login {
println!("Last login: {}", ll);
}
return;
}
// Auth save/update/login/delete
if data.get("saved").and_then(|v| v.as_bool()).unwrap_or(false) {
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or("");
println!("{} Auth profile '{}' saved", color::success_indicator(), name);
return;
}
if data.get("updated").and_then(|v| v.as_bool()).unwrap_or(false)
&& !data.get("saved").and_then(|v| v.as_bool()).unwrap_or(false) {
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or("");
println!("{} Auth profile '{}' updated", color::success_indicator(), name);
return;
}
if data.get("loggedIn").and_then(|v| v.as_bool()).unwrap_or(false) {
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or("");
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
println!("{} Logged in as '{}' - {}", color::success_indicator(), name, title);
} else {
println!("{} Logged in as '{}'", color::success_indicator(), name);
}
return;
}
if data.get("deleted").and_then(|v| v.as_bool()).unwrap_or(false) {
if let Some(name) = data.get("name").and_then(|v| v.as_str()) {
println!("{} Auth profile '{}' deleted", color::success_indicator(), name);
return;
}
}
// Confirmation required (for orchestrator use)
if data.get("confirmation_required").and_then(|v| v.as_bool()).unwrap_or(false) {
let category = data.get("category").and_then(|v| v.as_str()).unwrap_or("");
let description = data.get("description").and_then(|v| v.as_str()).unwrap_or("");
let cid = data.get("confirmation_id").and_then(|v| v.as_str()).unwrap_or("");
println!("Confirmation required:");
println!(" {}: {}", category, description);
println!(" Run: agent-browser confirm {}", cid);
println!(" Or: agent-browser deny {}", cid);
return;
}
if data.get("confirmed").and_then(|v| v.as_bool()).unwrap_or(false) {
println!("{} Action confirmed", color::success_indicator());
return;
}
if data.get("denied").and_then(|v| v.as_bool()).unwrap_or(false) {
println!("{} Action denied", color::success_indicator());
return;
}
// Default success
println!("{} Done", color::success_indicator());
}
@@ -1546,6 +1713,64 @@ Examples:
"##
}
// === Auth ===
"auth" => {
r##"
agent-browser auth - Manage authentication profiles
Usage: agent-browser auth <subcommand> [args]
Subcommands:
save <name> Save credentials for a login profile
login <name> Login using saved credentials
list List saved profiles (names and URLs only)
show <name> Show profile metadata (no passwords)
delete <name> Delete a saved profile
Save Options:
--url <url> Login page URL (required)
--username <user> Username (required)
--password <pass> Password (required unless --password-stdin)
--password-stdin Read password from stdin (recommended)
--username-selector <s> Custom CSS selector for username field
--password-selector <s> Custom CSS selector for password field
--submit-selector <s> Custom CSS selector for submit button
Global Options:
--json Output as JSON
--session <name> Use specific session
Examples:
echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin
agent-browser auth save github --url https://github.com/login --username user --password pass
agent-browser auth login github
agent-browser auth list
agent-browser auth show github
agent-browser auth delete github
"##
}
// === Confirm/Deny ===
"confirm" | "deny" => {
r##"
agent-browser confirm/deny - Approve or deny pending actions
Usage:
agent-browser confirm <confirmation-id>
agent-browser deny <confirmation-id>
When --confirm-actions is set, certain action categories return a
confirmation_required response with a confirmation ID. Use confirm/deny
to approve or reject the action.
Pending confirmations auto-deny after 60 seconds.
Examples:
agent-browser confirm c_8f3a1234
agent-browser deny c_8f3a1234
"##
}
// === Dialog ===
"dialog" => {
r##"
@@ -2071,6 +2296,17 @@ Debug:
errors [--clear] View page errors
highlight <sel> Highlight element
Auth Vault:
auth save <name> [opts] Save auth profile (--url, --username, --password/--password-stdin)
auth login <name> Login using saved credentials
auth list List saved auth profiles
auth show <name> Show auth profile metadata
auth delete <name> Delete auth profile
Confirmation:
confirm <id> Approve a pending action
deny <id> Deny a pending action
Sessions:
session Show current session name
session list List active sessions
@@ -2112,6 +2348,12 @@ Options:
--color-scheme <scheme> Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME)
--download-path <path> Default download directory (or AGENT_BROWSER_DOWNLOAD_PATH)
--session-name <name> Auto-save/restore session state (cookies, localStorage)
--content-boundaries Wrap page output in boundary markers (or AGENT_BROWSER_CONTENT_BOUNDARIES)
--max-output <chars> Truncate page output to N chars (or AGENT_BROWSER_MAX_OUTPUT)
--allowed-domains <list> Restrict navigation domains (or AGENT_BROWSER_ALLOWED_DOMAINS)
--action-policy <path> Action policy JSON file (or AGENT_BROWSER_ACTION_POLICY)
--confirm-actions <list> Categories requiring confirmation (or AGENT_BROWSER_CONFIRM_ACTIONS)
--confirm-interactive Interactive confirmation prompts; auto-denies if stdin is not a TTY (or AGENT_BROWSER_CONFIRM_INTERACTIVE)
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
--debug Debug output
--version, -V Show version
@@ -2161,6 +2403,12 @@ Environment:
AGENT_BROWSER_STREAM_PORT Enable WebSocket streaming on port (e.g., 9223)
AGENT_BROWSER_IOS_DEVICE Default iOS device name
AGENT_BROWSER_IOS_UDID Default iOS device UDID
AGENT_BROWSER_CONTENT_BOUNDARIES Wrap page output in boundary markers
AGENT_BROWSER_MAX_OUTPUT Max characters for page output
AGENT_BROWSER_ALLOWED_DOMAINS Comma-separated allowed domain patterns
AGENT_BROWSER_ACTION_POLICY Path to action policy JSON file
AGENT_BROWSER_CONFIRM_ACTIONS Action categories requiring confirmation
AGENT_BROWSER_CONFIRM_INTERACTIVE Enable interactive confirmation prompts
Install (recommended, fastest - native Rust CLI):
npm install -g agent-browser