#![allow(dead_code)]
use crate::agent::keyword_match;
use crate::tools::web_fetch::{classify_blocked_host, BlockedHostClass};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BlockedRequest {
pub class: BlockedHostClass,
}
impl BlockedRequest {
pub fn message(self) -> String {
format!("Navigation blocked: target is a {}", self.class.label())
}
}
pub fn validate_network_url(url: &str) -> Result<(), BlockedRequest> {
match classify_blocked_host(url) {
None => Ok(()),
Some(class) => Err(BlockedRequest { class }),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BrowserRiskClass {
Observation,
Navigation,
Mutation,
Administrative,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BrowserActionRisk {
pub class: BrowserRiskClass,
pub sensitive: bool,
pub consequential: bool,
}
const CONSEQUENTIAL_KEYWORDS: &[&str] = &[
"submit",
"purchase",
"delete",
"send",
"publish",
"permission",
"authentication",
"account",
"buy",
"checkout",
"pay",
"transfer",
"sign out",
"log out",
];
fn is_consequential(text: &str) -> bool {
CONSEQUENTIAL_KEYWORDS
.iter()
.any(|kw| keyword_match(text, kw))
}
pub fn classify(action: &str, selector: Option<&str>, script: Option<&str>) -> BrowserActionRisk {
match action {
"get_text" | "screenshot" | "wait" | "list_tabs" | "get_console_logs"
| "get_network_errors" => BrowserActionRisk {
class: BrowserRiskClass::Observation,
sensitive: false,
consequential: false,
},
"navigate" | "new_tab" | "switch_tab" => BrowserActionRisk {
class: BrowserRiskClass::Navigation,
sensitive: false,
consequential: false,
},
"click" | "fill" => BrowserActionRisk {
class: BrowserRiskClass::Mutation,
sensitive: false,
consequential: selector.map(is_consequential).unwrap_or(false),
},
"execute_js" => BrowserActionRisk {
class: BrowserRiskClass::Mutation,
sensitive: true,
consequential: script.map(is_consequential).unwrap_or(false),
},
"close" | "close_tab" | "set_mode" => BrowserActionRisk {
class: BrowserRiskClass::Administrative,
sensitive: false,
consequential: false,
},
_ => BrowserActionRisk {
class: BrowserRiskClass::Mutation,
sensitive: false,
consequential: false,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn observation_actions_map_correctly() {
for action in &[
"get_text",
"screenshot",
"wait",
"list_tabs",
"get_console_logs",
"get_network_errors",
] {
let r = classify(action, None, None);
assert_eq!(
r.class,
BrowserRiskClass::Observation,
"action={action} should be Observation"
);
assert!(!r.sensitive, "action={action} should not be sensitive");
assert!(
!r.consequential,
"action={action} should not be consequential"
);
}
}
#[test]
fn navigation_actions_map_correctly() {
for action in &["navigate", "new_tab", "switch_tab"] {
let r = classify(action, None, None);
assert_eq!(
r.class,
BrowserRiskClass::Navigation,
"action={action} should be Navigation"
);
assert!(!r.sensitive, "action={action} should not be sensitive");
assert!(
!r.consequential,
"action={action} should not be consequential"
);
}
}
#[test]
fn mutation_actions_click_fill_map_correctly() {
for action in &["click", "fill"] {
let r = classify(action, None, None);
assert_eq!(
r.class,
BrowserRiskClass::Mutation,
"action={action} should be Mutation"
);
assert!(!r.sensitive, "action={action} should not be sensitive");
assert!(
!r.consequential,
"action={action} should not be consequential"
);
}
}
#[test]
fn execute_js_is_always_mutation_and_sensitive() {
let r = classify("execute_js", None, Some("1 + 1"));
assert_eq!(r.class, BrowserRiskClass::Mutation);
assert!(r.sensitive, "execute_js must always be sensitive");
assert!(!r.consequential);
let r2 = classify("execute_js", None, None);
assert!(
r2.sensitive,
"execute_js with no script must still be sensitive"
);
}
#[test]
fn administrative_actions_map_correctly() {
for action in &["close", "close_tab", "set_mode"] {
let r = classify(action, None, None);
assert_eq!(
r.class,
BrowserRiskClass::Administrative,
"action={action} should be Administrative"
);
assert!(!r.sensitive, "action={action} should not be sensitive");
assert!(
!r.consequential,
"action={action} should not be consequential"
);
}
}
#[test]
fn unknown_action_defaults_to_mutation_not_sensitive_not_consequential() {
let r = classify("teleport_browser", None, None);
assert_eq!(
r.class,
BrowserRiskClass::Mutation,
"unknown actions should default to Mutation (most restrictive sane class)"
);
assert!(!r.sensitive);
assert!(!r.consequential);
}
#[test]
fn execute_js_always_sensitive_even_with_benign_script() {
let benign_scripts = [
"document.title",
"1 + 1",
"window.location.href",
"Array.from(document.querySelectorAll('a')).map(a => a.href)",
];
for script in &benign_scripts {
let r = classify("execute_js", None, Some(script));
assert!(
r.sensitive,
"execute_js with benign script '{script}' must still be sensitive=true"
);
}
}
#[test]
fn derived_forms_do_not_false_positive() {
let r = classify("click", Some("#deleted-items"), None);
assert!(
!r.consequential,
"'deleted' must not match 'delete' (word-boundary check)"
);
let r2 = classify("click", Some(".sender-info"), None);
assert!(
!r2.consequential,
"'sender' must not match 'send' (word-boundary check)"
);
let r3 = classify("execute_js", None, Some("form.submitted = true;"));
assert!(
!r3.consequential,
"'submitted' must not match 'submit' (word-boundary check)"
);
let r4 = classify("execute_js", None, Some("// this is a purchasing flow"));
assert!(
!r4.consequential,
"'purchasing' must not match 'purchase' (word-boundary check)"
);
let r5 = classify("click", Some(".publisher-name"), None);
assert!(
!r5.consequential,
"'publisher' must not match 'publish' (word-boundary check)"
);
}
#[test]
fn standalone_keyword_in_script_triggers_consequential() {
let r = classify("execute_js", None, Some("// call submit; await result"));
assert!(
r.consequential,
"'submit' as standalone word in script comment should be consequential"
);
let r2 = classify("execute_js", None, Some("// delete this record"));
assert!(
r2.consequential,
"'delete' as standalone word should be consequential"
);
let r3 = classify("execute_js", None, Some("// send email to user"));
assert!(
r3.consequential,
"'send' standalone in comment should be consequential"
);
let r4 = classify("execute_js", None, Some("initiating purchase flow"));
assert!(
r4.consequential,
"'purchase' standalone should be consequential"
);
let r_low_recall = classify(
"execute_js",
None,
Some("document.querySelector('form').submit()"),
);
assert!(
r_low_recall.sensitive,
"execute_js is always sensitive even when consequential is false"
);
}
#[test]
fn opaque_selector_is_not_consequential() {
let opaque_selectors = [
"#btn-3",
".checkout-cta",
"[data-id='42']",
"#submit-btn-container", ".pay-later-link", ];
for sel in &opaque_selectors {
let r = classify("click", Some(sel), None);
assert!(
!r.consequential,
"opaque selector '{sel}' must not be consequential"
);
}
}
#[test]
fn consequential_click_with_standalone_keyword_in_selector() {
let r = classify("click", Some("#delete"), None);
assert!(
r.consequential,
"selector '#delete' (→ token 'delete') must be consequential"
);
let r2 = classify("click", Some(".submit"), None);
assert!(
r2.consequential,
"selector '.submit' (→ token 'submit') must be consequential"
);
let r3 = classify("fill", Some("#account-email"), None);
assert!(
!r3.consequential,
"'#account-email' is hyphenated → does not match 'account' standalone"
);
}
#[test]
fn execute_js_consequential_scan_uses_script_not_selector() {
let r = classify("execute_js", Some("delete"), Some("1 + 1"));
assert!(
!r.consequential,
"execute_js consequential scan uses script, not selector"
);
assert!(r.sensitive);
}
#[test]
fn all_schema_actions_covered() {
let schema_actions = [
"navigate",
"screenshot",
"click",
"fill",
"get_text",
"execute_js",
"wait",
"list_tabs",
"new_tab",
"switch_tab",
"close_tab",
"set_mode",
"close",
];
let expected_classes = [
BrowserRiskClass::Navigation, BrowserRiskClass::Observation, BrowserRiskClass::Mutation, BrowserRiskClass::Mutation, BrowserRiskClass::Observation, BrowserRiskClass::Mutation, BrowserRiskClass::Observation, BrowserRiskClass::Observation, BrowserRiskClass::Navigation, BrowserRiskClass::Navigation, BrowserRiskClass::Administrative, BrowserRiskClass::Administrative, BrowserRiskClass::Administrative, ];
for (action, expected) in schema_actions.iter().zip(expected_classes.iter()) {
let r = classify(action, None, None);
assert_eq!(
r.class, *expected,
"action={action}: expected {expected:?}, got {:?}",
r.class
);
}
}
}