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
+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