native (#594)
* Native Rust rewrite of agent-browser daemon Single-binary Rust implementation replacing the Node.js/Playwright daemon with direct CDP (Chrome DevTools Protocol) communication. Includes full command parity, WebDriver/Safari/iOS backend routing, request tracking, frame context management, CDP protocol codegen, and comprehensive tests. * improvements * fix ci * fixes * faster builds
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Result of a policy check for an action.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PolicyResult {
|
||||
/// Action is allowed.
|
||||
Allow,
|
||||
/// Action is blocked with the given reason.
|
||||
Deny(String),
|
||||
/// Action requires confirmation before proceeding.
|
||||
RequiresConfirmation,
|
||||
}
|
||||
|
||||
/// Policy configuration loaded from a JSON file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActionPolicy {
|
||||
#[serde(skip)]
|
||||
path: PathBuf,
|
||||
#[serde(default)]
|
||||
default: Option<String>,
|
||||
#[serde(default)]
|
||||
allow: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
deny: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
confirm: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Confirmation categories parsed from AGENT_BROWSER_CONFIRM_ACTIONS.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfirmActions {
|
||||
pub categories: HashSet<String>,
|
||||
}
|
||||
|
||||
impl ConfirmActions {
|
||||
pub fn from_env() -> Option<Self> {
|
||||
let val = env::var("AGENT_BROWSER_CONFIRM_ACTIONS").ok()?;
|
||||
if val.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let categories: HashSet<String> = val
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if categories.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Self { categories })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn requires_confirmation(&self, action: &str) -> bool {
|
||||
self.categories.contains(action)
|
||||
}
|
||||
}
|
||||
|
||||
impl ActionPolicy {
|
||||
/// Load policy from a JSON file at the given path.
|
||||
pub fn load(path: &str) -> Result<Self, String> {
|
||||
let path_buf = PathBuf::from(path);
|
||||
let contents = fs::read_to_string(&path_buf)
|
||||
.map_err(|e| format!("Failed to read policy file: {}", e))?;
|
||||
let mut policy: ActionPolicy =
|
||||
serde_json::from_str(&contents).map_err(|e| format!("Invalid policy JSON: {}", e))?;
|
||||
policy.path = path_buf;
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
/// Load policy if AGENT_BROWSER_ACTION_POLICY env var is set.
|
||||
/// Falls back to AGENT_BROWSER_POLICY for backwards compatibility.
|
||||
pub fn load_if_exists() -> Option<Self> {
|
||||
let path = env::var("AGENT_BROWSER_ACTION_POLICY")
|
||||
.or_else(|_| env::var("AGENT_BROWSER_POLICY"))
|
||||
.ok()?;
|
||||
Self::load(&path).ok()
|
||||
}
|
||||
|
||||
/// Check whether an action is allowed, denied, or requires confirmation.
|
||||
pub fn check(&self, action: &str) -> PolicyResult {
|
||||
if let Some(deny) = &self.deny {
|
||||
if deny.iter().any(|a| a == action) {
|
||||
return PolicyResult::Deny(format!("Action '{}' is denied by policy", action));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(confirm) = &self.confirm {
|
||||
if confirm.iter().any(|a| a == action) {
|
||||
return PolicyResult::RequiresConfirmation;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(allow) = &self.allow {
|
||||
if !allow.is_empty() && !allow.iter().any(|a| a == action) {
|
||||
let is_default_deny = self
|
||||
.default
|
||||
.as_deref()
|
||||
.map(|d| d.eq_ignore_ascii_case("deny"))
|
||||
.unwrap_or(true);
|
||||
if is_default_deny {
|
||||
return PolicyResult::Deny(format!(
|
||||
"Action '{}' is not in the allow list",
|
||||
action
|
||||
));
|
||||
}
|
||||
}
|
||||
} else if let Some(ref default) = self.default {
|
||||
if default.eq_ignore_ascii_case("deny") {
|
||||
return PolicyResult::Deny(format!(
|
||||
"Action '{}' denied: default policy is deny",
|
||||
action
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
PolicyResult::Allow
|
||||
}
|
||||
|
||||
/// Reload policy from the file. Re-reads the JSON and updates the policy.
|
||||
pub fn reload(&mut self) -> Result<(), String> {
|
||||
let contents = fs::read_to_string(&self.path)
|
||||
.map_err(|e| format!("Failed to read policy file: {}", e))?;
|
||||
let mut policy: ActionPolicy =
|
||||
serde_json::from_str(&contents).map_err(|e| format!("Invalid policy JSON: {}", e))?;
|
||||
policy.path = self.path.clone();
|
||||
*self = policy;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_policy_allow_whitelist() {
|
||||
let json = r#"{"allow": ["click", "type"], "deny": [], "confirm": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("click"), PolicyResult::Allow);
|
||||
assert_eq!(policy.check("type"), PolicyResult::Allow);
|
||||
assert!(matches!(policy.check("navigate"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_deny() {
|
||||
let json = r#"{"allow": [], "deny": ["delete"], "confirm": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(policy.check("delete"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_confirm() {
|
||||
let json = r#"{"allow": [], "deny": [], "confirm": ["submit"]}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("submit"), PolicyResult::RequiresConfirmation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_deny_takes_precedence() {
|
||||
let json = r#"{"allow": ["danger"], "deny": ["danger"], "confirm": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(policy.check("danger"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_confirm_takes_precedence_over_allow() {
|
||||
let json = r#"{"allow": ["submit"], "deny": [], "confirm": ["submit"]}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("submit"), PolicyResult::RequiresConfirmation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_empty_allow_allows_all() {
|
||||
let json = r#"{"allow": [], "deny": [], "confirm": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("anything"), PolicyResult::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_missing_allow_allows_all() {
|
||||
let json = r#"{"deny": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("anything"), PolicyResult::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_default_allow() {
|
||||
let json = r#"{"default": "allow", "deny": ["navigate"]}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("click"), PolicyResult::Allow);
|
||||
assert!(matches!(policy.check("navigate"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_default_deny() {
|
||||
let json = r#"{"default": "deny", "allow": ["click"]}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("click"), PolicyResult::Allow);
|
||||
assert!(matches!(policy.check("navigate"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_confirm_actions_from_env() {
|
||||
env::set_var("AGENT_BROWSER_CONFIRM_ACTIONS", "navigate,click,fill");
|
||||
let ca = ConfirmActions::from_env().unwrap();
|
||||
assert!(ca.requires_confirmation("navigate"));
|
||||
assert!(ca.requires_confirmation("click"));
|
||||
assert!(ca.requires_confirmation("fill"));
|
||||
assert!(!ca.requires_confirmation("screenshot"));
|
||||
env::remove_var("AGENT_BROWSER_CONFIRM_ACTIONS");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user