diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/authorization.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/authorization.rs index f46428f54d..02cc3107fc 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/authorization.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/authorization.rs @@ -84,6 +84,34 @@ pub struct EnforcementAdapterDescriptor { pub revocation_triggers: &'static [&'static str], pub refusal_code: Option<&'static str>, pub provider_requirement: &'static str, + /// Mode-specific truth. `state` remains the standard-mode value for + /// backward-compatible inventory consumers. + pub enforcement_by_mode: AdapterEnforcement, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct AdapterEnforcement { + pub standard: RiskEnforcement, + pub bounded: RiskEnforcement, + pub unrestricted: RiskEnforcement, +} + +impl AdapterEnforcement { + pub const fn uniform(state: RiskEnforcement) -> Self { + Self { + standard: state, + bounded: state, + unrestricted: state, + } + } + + pub const fn for_mode(self, mode: PermissionMode) -> RiskEnforcement { + match mode { + PermissionMode::Standard => self.standard, + PermissionMode::Bounded => self.bounded, + PermissionMode::Unrestricted => self.unrestricted, + } + } } const EXISTING_PROFILE_OPERATIONS: &[&str] = &["browser_prepare[strategy.kind=existing_profile]"]; @@ -113,13 +141,31 @@ const EXISTING_PROFILE_REVOCATION: &[&str] = &[ ]; const PRIVATE_OBSERVATION_OPERATIONS: &[&str] = &[ - "get_desktop_state[without_file_output]", + "get_desktop_state", "get_accessibility_tree", - "get_window_state[without_file_output]", + "get_window_state", + "list_apps", + "list_windows", + "debug_window_info", + "get_browser_state", + "browser_dialog[action=inspect]", "page[action=get_text|query_dom]", + "escalate_session", + "zoom", + "start_recording", +]; +const PRIVATE_OBSERVATION_SCOPE_KEYS: &[&str] = &[ + "daemon_generation", + "public_session", + "pid", + "process_fingerprint", + "window_id", + "display_generation", + "capture_scope", + "permission_mode", + "managed_policy_sha256", + "user_policy_sha256", ]; -const PRIVATE_OBSERVATION_SCOPE_KEYS: &[&str] = - &["public_session", "pid", "window_id", "display_generation"]; const DESKTOP_INPUT_OPERATIONS: &[&str] = &[ "click", @@ -132,6 +178,7 @@ const DESKTOP_INPUT_OPERATIONS: &[&str] = &[ "mouse_button_up", "mouse_drag", "parallel_mouse_drag", + "replay_trajectory", "type_text", "type_text_chars", "press_key", @@ -140,11 +187,16 @@ const DESKTOP_INPUT_OPERATIONS: &[&str] = &[ "bring_to_front", ]; const DESKTOP_INPUT_SCOPE_KEYS: &[&str] = &[ + "daemon_generation", "public_session", "pid", + "process_fingerprint", "window_id", "display_generation", "delivery_mode_ceiling", + "permission_mode", + "managed_policy_sha256", + "user_policy_sha256", ]; const FILE_TRANSFER_OPERATIONS: &[&str] = &[ @@ -152,21 +204,72 @@ const FILE_TRANSFER_OPERATIONS: &[&str] = &[ "browser_download", "get_desktop_state[with_file_output]", "get_window_state[with_file_output]", + "start_recording", + "stop_recording", + "replay_trajectory", + "install_ffmpeg", ]; const FILE_TRANSFER_SCOPE_KEYS: &[&str] = &[ + "daemon_generation", "public_session", "browser_binding", "tab", "canonical_path", "destination_class", + "direction", + "permission_mode", + "managed_policy_sha256", + "user_policy_sha256", ]; const CONSEQUENTIAL_OPERATIONS: &[&str] = &[ "browser_dialog[action=accept|dismiss]", "page[action!=get_text|query_dom]", ]; -const CONSEQUENTIAL_SCOPE_KEYS: &[&str] = - &["public_session", "browser_binding", "tab", "action_kind"]; +const CONSEQUENTIAL_SCOPE_KEYS: &[&str] = &[ + "daemon_generation", + "public_session", + "browser_binding", + "tab", + "origin", + "action_kind", + "permission_mode", + "managed_policy_sha256", + "user_policy_sha256", +]; + +const UNBOUNDED_SCRIPT_OPERATIONS: &[&str] = &["page[action=execute_javascript]"]; + +const BROWSER_BOUND_INPUT_OPERATIONS: &[&str] = &[ + "browser_navigate", + "browser_click", + "browser_type", + "browser_pointer", +]; +const BROWSER_BOUND_INPUT_SCOPE_KEYS: &[&str] = &[ + "daemon_generation", + "public_session", + "browser_binding", + "tab", + "origin", + "permission_mode", + "managed_policy_sha256", + "user_policy_sha256", +]; + +const PROCESS_CONTROL_OPERATIONS: &[&str] = &["kill_app"]; +const PROCESS_CONTROL_SCOPE_KEYS: &[&str] = &[ + "daemon_generation", + "public_session", + "pid", + "process_fingerprint", + "permission_mode", + "managed_policy_sha256", + "user_policy_sha256", +]; + +const OS_PERMISSION_PROMPT_OPERATIONS: &[&str] = &["check_permissions[prompt=true]"]; +const DRIVER_CONFIGURATION_OPERATIONS: &[&str] = &["set_config"]; const SESSION_REVOCATION: &[&str] = &[ "indicator_stop", @@ -193,6 +296,7 @@ pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ refusal_code: Some("browser_consent_required"), provider_requirement: "protected_consent_in_standard; protected_indicator_in_bounded; none_in_unrestricted", + enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::Active), }, EnforcementAdapterDescriptor { id: "private_observation", @@ -208,6 +312,7 @@ pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ revocation_triggers: SESSION_REVOCATION, refusal_code: None, provider_requirement: "certified_protected_host_not_implemented", + enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::MetadataOnly), }, EnforcementAdapterDescriptor { id: "desktop_input", @@ -223,6 +328,7 @@ pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ revocation_triggers: SESSION_REVOCATION, refusal_code: None, provider_requirement: "certified_protected_host_not_implemented", + enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::MetadataOnly), }, EnforcementAdapterDescriptor { id: "file_transfer_and_output", @@ -238,6 +344,7 @@ pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ revocation_triggers: SESSION_REVOCATION, refusal_code: None, provider_requirement: "certified_protected_host_not_implemented", + enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::MetadataOnly), }, EnforcementAdapterDescriptor { id: "browser_consequential_action", @@ -253,6 +360,108 @@ pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ revocation_triggers: SESSION_REVOCATION, refusal_code: None, provider_requirement: "certified_protected_host_not_implemented", + enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::MetadataOnly), + }, + EnforcementAdapterDescriptor { + id: "browser_unbounded_script", + operations: UNBOUNDED_SCRIPT_OPERATIONS, + state: RiskEnforcement::MetadataOnly, + risk_class: RiskClass::R3, + resource_kind: "unbounded_authenticated_page_script", + scope_keys: &[], + grant_type: None, + idle_ttl_seconds: None, + absolute_ttl_seconds: None, + indicator_requirement: "not_grantable_in_standard_or_bounded", + revocation_triggers: &[], + refusal_code: None, + provider_requirement: "typed_bounded_adapter_required_before_activation", + enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::MetadataOnly), + }, + EnforcementAdapterDescriptor { + id: "browser_bound_input", + operations: BROWSER_BOUND_INPUT_OPERATIONS, + state: RiskEnforcement::MetadataOnly, + risk_class: RiskClass::R2, + resource_kind: "authenticated_browser_binding_and_tab_input", + scope_keys: BROWSER_BOUND_INPUT_SCOPE_KEYS, + grant_type: None, + idle_ttl_seconds: None, + absolute_ttl_seconds: None, + indicator_requirement: "not_implemented", + revocation_triggers: SESSION_REVOCATION, + refusal_code: None, + provider_requirement: "certified_protected_host_not_implemented", + enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::MetadataOnly), + }, + EnforcementAdapterDescriptor { + id: "process_control", + operations: PROCESS_CONTROL_OPERATIONS, + state: RiskEnforcement::MetadataOnly, + risk_class: RiskClass::R3, + resource_kind: "exact_process_termination", + scope_keys: PROCESS_CONTROL_SCOPE_KEYS, + grant_type: None, + idle_ttl_seconds: None, + absolute_ttl_seconds: None, + indicator_requirement: "not_implemented", + revocation_triggers: SESSION_REVOCATION, + refusal_code: None, + provider_requirement: "certified_protected_host_not_implemented", + enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::MetadataOnly), + }, + EnforcementAdapterDescriptor { + id: "os_permission_prompt", + operations: OS_PERMISSION_PROMPT_OPERATIONS, + state: RiskEnforcement::MetadataOnly, + risk_class: RiskClass::R2, + resource_kind: "operating_system_permission_prompt", + scope_keys: &["daemon_generation", "permission_mode"], + grant_type: None, + idle_ttl_seconds: None, + absolute_ttl_seconds: None, + indicator_requirement: "never_agent_controllable", + revocation_triggers: &[], + refusal_code: None, + provider_requirement: "trusted_host_setup_required_before_activation", + enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::MetadataOnly), + }, + EnforcementAdapterDescriptor { + id: "driver_configuration", + operations: DRIVER_CONFIGURATION_OPERATIONS, + state: RiskEnforcement::MetadataOnly, + risk_class: RiskClass::R2, + resource_kind: "persistent_driver_configuration", + scope_keys: &[ + "daemon_generation", + "permission_mode", + "managed_policy_sha256", + "user_policy_sha256", + ], + grant_type: None, + idle_ttl_seconds: None, + absolute_ttl_seconds: None, + indicator_requirement: "not_implemented", + revocation_triggers: SESSION_REVOCATION, + refusal_code: None, + provider_requirement: "certified_protected_host_not_implemented", + enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::MetadataOnly), + }, + EnforcementAdapterDescriptor { + id: "clipboard", + operations: &["clipboard_read", "clipboard_write"], + state: RiskEnforcement::NotExposed, + risk_class: RiskClass::Unclassified, + resource_kind: "system_clipboard", + scope_keys: &[], + grant_type: None, + idle_ttl_seconds: None, + absolute_ttl_seconds: None, + indicator_requirement: "required_before_exposure", + revocation_triggers: &[], + refusal_code: None, + provider_requirement: "capability_not_exposed", + enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::NotExposed), }, EnforcementAdapterDescriptor { id: "devices", @@ -268,6 +477,7 @@ pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ revocation_triggers: &[], refusal_code: None, provider_requirement: "capability_not_exposed", + enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::NotExposed), }, EnforcementAdapterDescriptor { id: "shell_and_network", @@ -283,6 +493,7 @@ pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ revocation_triggers: &[], refusal_code: None, provider_requirement: "capability_not_exposed", + enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::NotExposed), }, ]; @@ -298,6 +509,130 @@ pub fn adapter_ids_with_state(state: RiskEnforcement) -> Vec<&'static str> { .collect() } +pub fn adapter_ids_with_state_for_mode( + mode: PermissionMode, + state: RiskEnforcement, +) -> Vec<&'static str> { + ENFORCEMENT_ADAPTERS + .iter() + .filter(|adapter| adapter.enforcement_by_mode.for_mode(mode) == state) + .map(|adapter| adapter.id) + .collect() +} + +/// Return every resource adapter that composes the exact call. +/// +/// A call may intentionally require more than one adapter: screenshot output +/// is both private observation and a file write; trajectory recording is both +/// observation and file output; arbitrary page script is both consequential +/// and explicitly unbounded. Admission must satisfy the conjunction rather +/// than picking the highest-risk adapter and dropping the other boundary. +pub fn enforcement_adapters_for_call( + tool: &str, + args: &Value, +) -> Vec<&'static EnforcementAdapterDescriptor> { + let mut ids = Vec::new(); + let mut add = |id| { + if !ids.contains(&id) { + ids.push(id); + } + }; + + if tool == "browser_prepare" + && args.pointer("/strategy/kind").and_then(Value::as_str) == Some("existing_profile") + { + add("browser_prepare.existing_profile"); + } + + if matches!( + tool, + "get_desktop_state" + | "get_accessibility_tree" + | "get_window_state" + | "list_apps" + | "list_windows" + | "debug_window_info" + | "get_browser_state" + | "escalate_session" + | "zoom" + | "start_recording" + ) || (tool == "page" + && matches!( + args.get("action").and_then(Value::as_str), + Some("get_text" | "query_dom") + )) + || (tool == "browser_dialog" + && args.get("action").and_then(Value::as_str) == Some("inspect")) + { + add("private_observation"); + } + + if DESKTOP_INPUT_OPERATIONS.contains(&tool) { + add("desktop_input"); + } + + let writes_screenshot = matches!(tool, "get_desktop_state" | "get_window_state") + && args + .get("screenshot_out_file") + .and_then(Value::as_str) + .is_some_and(|path| !path.is_empty()); + if matches!( + tool, + "browser_set_input_files" + | "browser_download" + | "start_recording" + | "stop_recording" + | "replay_trajectory" + | "install_ffmpeg" + ) || writes_screenshot + { + add("file_transfer_and_output"); + } + + if (tool == "browser_dialog" + && matches!( + args.get("action").and_then(Value::as_str), + Some("accept" | "dismiss") + )) + || (tool == "page" + && !matches!( + args.get("action").and_then(Value::as_str), + Some("get_text" | "query_dom") + )) + { + add("browser_consequential_action"); + } + + if tool == "page" && args.get("action").and_then(Value::as_str) == Some("execute_javascript") { + add("browser_unbounded_script"); + } + + if BROWSER_BOUND_INPUT_OPERATIONS.contains(&tool) { + add("browser_bound_input"); + } + + if PROCESS_CONTROL_OPERATIONS.contains(&tool) { + add("process_control"); + } + + if tool == "check_permissions" && args.get("prompt").and_then(Value::as_bool).unwrap_or(true) { + add("os_permission_prompt"); + } + + if DRIVER_CONFIGURATION_OPERATIONS.contains(&tool) { + add("driver_configuration"); + } + + ids.into_iter() + .map(|id| { + ENFORCEMENT_ADAPTERS + .iter() + .find(|adapter| adapter.id == id) + .expect("call mapping references a declared enforcement adapter") + }) + .collect() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RiskAssessment { pub class: RiskClass, @@ -312,7 +647,6 @@ pub fn advertised_risk_for(tool: &str) -> RiskAssessment { // Public driver/OS metadata with no user-content payload. "get_screen_size" | "get_cursor_position" - | "check_permissions" | "get_config" | "get_session_state" | "get_agent_cursor_state" @@ -338,22 +672,20 @@ pub fn advertised_risk_for(tool: &str) -> RiskAssessment { | "hotkey" | "set_value" | "launch_app" - | "list_apps" - | "kill_app" - | "list_windows" | "bring_to_front" - | "debug_window_info" | "start_session" | "end_session" | "set_agent_cursor_enabled" | "set_agent_cursor_motion" - | "set_agent_cursor_style" - | "stop_recording" - | "replay_trajectory" => RiskClass::R1, + | "set_agent_cursor_style" => RiskClass::R1, // Surfaces that can reveal or control sensitive local/authenticated // state. Most remain metadata-only until their resource adapters ship. "zoom" + | "list_apps" + | "list_windows" + | "debug_window_info" + | "check_permissions" | "get_accessibility_tree" | "set_config" | "escalate_session" @@ -368,6 +700,9 @@ pub fn advertised_risk_for(tool: &str) -> RiskAssessment { // External/file side effects or generic compound action surfaces. "get_desktop_state" | "get_window_state" + | "kill_app" + | "stop_recording" + | "replay_trajectory" | "install_ffmpeg" | "page" | "browser_dialog" @@ -379,14 +714,7 @@ pub fn advertised_risk_for(tool: &str) -> RiskAssessment { RiskAssessment { class, enforcement: RiskEnforcement::MetadataOnly, - operation_sensitive: matches!( - tool, - "browser_prepare" - | "browser_dialog" - | "page" - | "get_desktop_state" - | "get_window_state" - ), + operation_sensitive: matches!(class, RiskClass::R2 | RiskClass::R3 | RiskClass::R4), } } @@ -448,6 +776,15 @@ pub fn classify_tool_call(tool: &str, args: &Value) -> RiskAssessment { enforcement: RiskEnforcement::MetadataOnly, operation_sensitive: true, }, + "check_permissions" => RiskAssessment { + class: if args.get("prompt").and_then(Value::as_bool).unwrap_or(true) { + RiskClass::R2 + } else { + RiskClass::R0 + }, + enforcement: RiskEnforcement::MetadataOnly, + operation_sensitive: args.get("prompt").and_then(Value::as_bool).unwrap_or(true), + }, _ => advertised_risk_for(tool), } } @@ -722,11 +1059,33 @@ pub fn validate_startup_authorization() -> anyhow::Result<()> { /// Content-free authorization state suitable for status/health output. pub fn status_json() -> serde_json::Value { + status_json_with_provider(None) +} + +/// Content-free authorization state for one runtime-owned provider. +/// +/// Provider identity is runtime state, never a process-global first-writer +/// value: multiple direct runtimes may install different trusted hosts. +pub fn status_json_with_provider( + protected_consent_collector: Option<&'static str>, +) -> serde_json::Value { let mode = configured_permission_mode(); let policy = crate::policy::configured_policy(); let managed_policy = crate::policy::configured_managed_policy(); let user_policy_sha256 = crate::policy::user_policy_sha256().ok().flatten(); let managed_policy_sha256 = crate::policy::managed_policy_sha256().ok().flatten(); + let effective_active_risk_enforcement = mode + .as_ref() + .map(|mode| adapter_ids_with_state_for_mode(*mode, RiskEnforcement::Active)) + .unwrap_or_default(); + let effective_metadata_only_risk_enforcement = mode + .as_ref() + .map(|mode| adapter_ids_with_state_for_mode(*mode, RiskEnforcement::MetadataOnly)) + .unwrap_or_default(); + let effective_not_exposed_risk_enforcement = mode + .as_ref() + .map(|mode| adapter_ids_with_state_for_mode(*mode, RiskEnforcement::NotExposed)) + .unwrap_or_default(); let session_policy = crate::session_manifest::configured_session_manifest(); let session_policy_status = session_policy .as_ref() @@ -766,8 +1125,11 @@ pub fn status_json() -> serde_json::Value { "active_risk_enforcement": adapter_ids_with_state(RiskEnforcement::Active), "metadata_only_risk_enforcement": adapter_ids_with_state(RiskEnforcement::MetadataOnly), "not_exposed_risk_enforcement": adapter_ids_with_state(RiskEnforcement::NotExposed), + "effective_active_risk_enforcement": effective_active_risk_enforcement, + "effective_metadata_only_risk_enforcement": effective_metadata_only_risk_enforcement, + "effective_not_exposed_risk_enforcement": effective_not_exposed_risk_enforcement, "enforcement_adapters": enforcement_adapter_inventory_json(), - "protected_consent_collector": crate::consent::configured_provider_id(), + "protected_consent_collector": protected_consent_collector, "session_policy_configured": std::env::var_os(crate::session_manifest::SESSION_POLICY_FILE_ENV).is_some(), "session_policy_approved_at_startup": env_flag(crate::session_manifest::SESSION_POLICY_APPROVED_ENV), "session_policy_valid": session_policy.is_ok(), @@ -898,6 +1260,24 @@ mod tests { } } + #[test] + fn os_permission_prompt_is_argument_sensitive() { + let read_only = + classify_tool_call("check_permissions", &serde_json::json!({"prompt": false})); + assert_eq!(read_only.class, RiskClass::R0); + assert!(!read_only.operation_sensitive); + + for args in [serde_json::json!({}), serde_json::json!({"prompt": true})] { + let prompting = classify_tool_call("check_permissions", &args); + assert_eq!(prompting.class, RiskClass::R2); + assert!(prompting.operation_sensitive); + } + assert_eq!( + advertised_risk_for("check_permissions").class, + RiskClass::R2 + ); + } + #[test] fn process_targeted_tools_cannot_target_the_authorization_daemon() { let error = authorize_tool_call( @@ -922,6 +1302,11 @@ mod tests { "adapter {} needs at least one operation selector", adapter.id ); + assert_eq!( + adapter.state, adapter.enforcement_by_mode.standard, + "legacy state must remain the standard-mode value for {}", + adapter.id + ); } assert_eq!( @@ -935,11 +1320,20 @@ mod tests { "desktop_input", "file_transfer_and_output", "browser_consequential_action", + "browser_unbounded_script", + "browser_bound_input", + "process_control", + "os_permission_prompt", + "driver_configuration", ] ); assert_eq!( adapter_ids_with_state(RiskEnforcement::NotExposed), - vec!["devices", "shell_and_network"] + vec!["clipboard", "devices", "shell_and_network"] + ); + assert_eq!( + adapter_ids_with_state_for_mode(PermissionMode::Standard, RiskEnforcement::Active), + vec!["browser_prepare.existing_profile"] ); let existing = ENFORCEMENT_ADAPTERS @@ -951,6 +1345,72 @@ mod tests { assert_eq!(existing.refusal_code, Some("browser_consent_required")); } + #[test] + fn exact_call_inventory_composes_overlapping_resource_boundaries() { + let ids = |tool, args: Value| { + enforcement_adapters_for_call(tool, &args) + .into_iter() + .map(|adapter| adapter.id) + .collect::>() + }; + + assert_eq!( + ids( + "get_desktop_state", + serde_json::json!({"screenshot_out_file": "/tmp/capture.png"}) + ), + vec!["private_observation", "file_transfer_and_output"] + ); + assert_eq!( + ids("page", serde_json::json!({"action": "execute_javascript"})), + vec!["browser_consequential_action", "browser_unbounded_script"] + ); + assert_eq!( + ids("start_recording", serde_json::json!({})), + vec!["private_observation", "file_transfer_and_output"] + ); + assert_eq!( + ids("escalate_session", serde_json::json!({})), + vec!["private_observation"] + ); + assert_eq!( + ids("type_text_chars", serde_json::json!({})), + vec!["desktop_input"] + ); + assert_eq!( + ids("replay_trajectory", serde_json::json!({})), + vec!["desktop_input", "file_transfer_and_output"] + ); + assert_eq!( + ids("get_browser_state", serde_json::json!({})), + vec!["private_observation"] + ); + assert_eq!( + ids("browser_dialog", serde_json::json!({"action": "inspect"})), + vec!["private_observation"] + ); + assert_eq!( + ids("browser_click", serde_json::json!({})), + vec!["browser_bound_input"] + ); + assert_eq!( + ids("kill_app", serde_json::json!({})), + vec!["process_control"] + ); + assert_eq!( + ids("check_permissions", serde_json::json!({"prompt": false})), + Vec::<&str>::new() + ); + assert_eq!( + ids("check_permissions", serde_json::json!({})), + vec!["os_permission_prompt"] + ); + assert_eq!( + ids("set_config", serde_json::json!({})), + vec!["driver_configuration"] + ); + } + #[test] fn status_derives_adapter_summaries_from_the_inventory() { let status = status_json(); @@ -964,9 +1424,26 @@ mod tests { "private_observation", "desktop_input", "file_transfer_and_output", - "browser_consequential_action" + "browser_consequential_action", + "browser_unbounded_script", + "browser_bound_input", + "process_control", + "os_permission_prompt", + "driver_configuration" ]) ); + assert_eq!( + status["not_exposed_risk_enforcement"], + serde_json::json!(["clipboard", "devices", "shell_and_network"]) + ); + assert_eq!( + status["effective_active_risk_enforcement"], + serde_json::json!(["browser_prepare.existing_profile"]) + ); + assert_eq!( + status["effective_metadata_only_risk_enforcement"], + status["metadata_only_risk_enforcement"] + ); assert_eq!( status["enforcement_adapters"], enforcement_adapter_inventory_json() diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/browser/engine.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/browser/engine.rs index 43c8e7cb3b..72613e259d 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/browser/engine.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/browser/engine.rs @@ -77,7 +77,7 @@ pub struct BrowserEngine { pub(crate) pool: CdpPool, pub(crate) managed_browsers: ManagedBrowsers, pub(crate) existing_profile_grants: ExistingProfileGrants, - pub(crate) approval_broker: crate::consent::ApprovalBroker, + pub(crate) approval_broker: Arc, mutation_gates: MutationGates, reconnect_gates: ReconnectGates, session_end_hook: Mutex>, @@ -525,7 +525,10 @@ impl BrowserEngine { /// capability store. Platform crates call this once and register /// the five tools via `register_browser_tools`. pub fn new(platform: Arc) -> Arc { - Self::new_with_protected_consent_provider(platform, None) + Self::new_with_approval_broker( + platform, + Arc::new(crate::consent::ApprovalBroker::unavailable()), + ) } /// Create an engine with a provider installed by a trusted embedding host @@ -534,6 +537,18 @@ impl BrowserEngine { pub fn new_with_protected_consent_provider( platform: Arc, provider: Option>, + ) -> Arc { + Self::new_with_approval_broker( + platform, + Arc::new(crate::consent::ApprovalBroker::new(provider)), + ) + } + + /// Create an engine using the runtime-owned broker shared by every + /// protected resource adapter. + pub fn new_with_approval_broker( + platform: Arc, + approval_broker: Arc, ) -> Arc { let engine = Arc::new(Self { platform, @@ -541,7 +556,7 @@ impl BrowserEngine { pool: CdpPool::new(), managed_browsers: Default::default(), existing_profile_grants: ExistingProfileGrants::new(), - approval_broker: crate::consent::ApprovalBroker::new(provider), + approval_broker, mutation_gates: MutationGates::new(), reconnect_gates: ReconnectGates::new(), session_end_hook: Mutex::new(None), diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/consent.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/consent.rs index 30b2567281..e94eba00e0 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/consent.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/consent.rs @@ -7,9 +7,10 @@ //! implementations must authenticate their private channel before adapting it //! to this interface. +use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, OnceLock}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -20,11 +21,6 @@ use uuid::Uuid; use crate::authorization::{PermissionMode, RiskClass}; const DEFAULT_REQUEST_TTL: Duration = Duration::from_secs(2 * 60); -static CONFIGURED_PROVIDER_ID: OnceLock<&'static str> = OnceLock::new(); - -pub fn configured_provider_id() -> Option<&'static str> { - CONFIGURED_PROVIDER_ID.get().copied() -} #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -147,9 +143,6 @@ impl ApprovalBroker { } pub fn new(provider: Option>) -> Self { - if let Some(provider) = provider.as_ref() { - let _ = CONFIGURED_PROVIDER_ID.set(provider.provider_id()); - } Self { daemon_instance: Arc::from(Uuid::new_v4().to_string()), next_generation: Arc::new(AtomicU64::new(1)), @@ -173,6 +166,32 @@ impl ApprovalBroker { transport_session: impl Into, resource: Value, human_summary: impl Into, + ) -> ConsentRequest { + self.request_bound( + permission_mode, + operation, + risk_class, + public_session, + transport_session, + resource, + human_summary, + crate::policy::managed_policy_sha256().ok().flatten(), + crate::policy::user_policy_sha256().ok().flatten(), + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn request_bound( + &self, + permission_mode: PermissionMode, + operation: impl Into, + risk_class: RiskClass, + public_session: impl Into, + transport_session: impl Into, + resource: Value, + human_summary: impl Into, + managed_policy_sha256: Option, + user_policy_sha256: Option, ) -> ConsentRequest { let expires_unix_ms = now_unix_ms() + DEFAULT_REQUEST_TTL.as_millis(); let mut request = ConsentRequest { @@ -181,8 +200,8 @@ impl ApprovalBroker { generation: self.next_generation.fetch_add(1, Ordering::Relaxed), daemon_instance: self.daemon_instance.to_string(), permission_mode, - managed_policy_sha256: crate::policy::managed_policy_sha256().ok().flatten(), - user_policy_sha256: crate::policy::user_policy_sha256().ok().flatten(), + managed_policy_sha256, + user_policy_sha256, operation: operation.into(), risk_class, public_session: public_session.into(), @@ -280,6 +299,219 @@ impl ApprovalBroker { } } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ResourceGrantKey { + runtime_scope: String, + public_session: String, + transport_session: String, + adapter_id: String, + resource_digest: String, +} + +#[derive(Debug, Clone)] +pub struct ResourceGrant { + pub adapter_id: String, + pub resource_digest: String, + pub protected: ProtectedGrant, + mode: PermissionMode, + managed_policy_sha256: Option, + user_policy_sha256: Option, + created_at: Instant, + last_used_at: Instant, + idle_ttl: Duration, + absolute_ttl: Duration, +} + +impl ResourceGrant { + pub fn is_live(&self) -> bool { + self.protected.is_live() + } + + fn expired(&self, now: Instant) -> bool { + !self.is_live() + || now.duration_since(self.last_used_at) >= self.idle_ttl + || now.duration_since(self.created_at) >= self.absolute_ttl + } +} + +/// Per-runtime store and admission coordinator for every protected resource +/// adapter beyond existing-profile attachment. +/// +/// Only canonical resource digests become map keys. Raw paths, text, page +/// content, selectors, and typed input are never retained by this store. +pub struct ProtectedResourceGrants { + broker: Arc, + grants: Mutex>, + admission: tokio::sync::Mutex<()>, +} + +impl ProtectedResourceGrants { + pub fn new(broker: Arc) -> Self { + Self { + broker, + grants: Mutex::new(HashMap::new()), + admission: tokio::sync::Mutex::new(()), + } + } + + #[allow(clippy::too_many_arguments)] + pub async fn authorize( + &self, + context: &crate::session_authorization::EffectiveAuthorizationContext, + adapter_id: &str, + risk_class: RiskClass, + lifecycle_session: Option<&str>, + resource: Value, + human_summary: &str, + idle_ttl: Duration, + absolute_ttl: Duration, + ) -> Result, ConsentError> { + if context.mode() == PermissionMode::Unrestricted { + return Ok(None); + } + let resource_digest = digest_resource(adapter_id, &resource); + let key = resource_grant_key(context, lifecycle_session, adapter_id, &resource_digest); + if let Some(grant) = self.lookup(&key, context) { + return Ok(Some(grant)); + } + + // Collapse concurrent requests for the same or different protected + // resources into a single provider turn. Re-check after waiting so a + // peer that just approved the same exact scope supplies the grant. + let _admission = self.admission.lock().await; + if let Some(grant) = self.lookup(&key, context) { + return Ok(Some(grant)); + } + + let public_session = context + .public_session() + .or(lifecycle_session) + .unwrap_or("compatibility"); + let transport_session = context.transport_session().unwrap_or(public_session); + let request = self.broker.request_bound( + context.mode(), + adapter_id, + risk_class, + public_session, + transport_session, + resource, + human_summary, + context.managed_policy_sha256().map(str::to_owned), + context.user_policy_sha256().map(str::to_owned), + ); + let protected = match context.mode() { + PermissionMode::Standard => self.broker.approve(&request).await?, + PermissionMode::Bounded => self.broker.activate_preapproved(&request).await?, + PermissionMode::Unrestricted => unreachable!("handled before protected admission"), + }; + let now = Instant::now(); + let grant = ResourceGrant { + adapter_id: adapter_id.to_owned(), + resource_digest, + protected, + mode: context.mode(), + managed_policy_sha256: context.managed_policy_sha256().map(str::to_owned), + user_policy_sha256: context.user_policy_sha256().map(str::to_owned), + created_at: now, + last_used_at: now, + idle_ttl, + absolute_ttl, + }; + self.grants.lock().unwrap().insert(key, grant.clone()); + Ok(Some(grant)) + } + + fn lookup( + &self, + key: &ResourceGrantKey, + context: &crate::session_authorization::EffectiveAuthorizationContext, + ) -> Option { + let now = Instant::now(); + let mut grants = self.grants.lock().unwrap(); + let grant = grants.get_mut(key)?; + if grant.expired(now) + || grant.mode != context.mode() + || grant.managed_policy_sha256.as_deref() != context.managed_policy_sha256() + || grant.user_policy_sha256.as_deref() != context.user_policy_sha256() + { + if let Some(expired) = grants.remove(key) { + expired.protected.indicator.revoke(); + } + return None; + } + grant.last_used_at = now; + Some(grant.clone()) + } + + pub fn revoke_session(&self, session: &str) -> Vec { + let mut removed = Vec::new(); + self.grants.lock().unwrap().retain(|key, grant| { + let keep = key.public_session != session && key.transport_session != session; + if !keep { + grant.protected.indicator.revoke(); + removed.push(grant.protected.clone()); + } + keep + }); + removed + } + + pub fn revoke_all(&self) -> Vec { + let mut grants = self.grants.lock().unwrap(); + let removed = grants + .drain() + .map(|(_, grant)| { + grant.protected.indicator.revoke(); + grant.protected + }) + .collect(); + removed + } + + pub fn broker(&self) -> &Arc { + &self.broker + } +} + +impl Drop for ProtectedResourceGrants { + fn drop(&mut self) { + for (_, grant) in self.grants.get_mut().unwrap().drain() { + grant.protected.indicator.revoke(); + } + } +} + +fn resource_grant_key( + context: &crate::session_authorization::EffectiveAuthorizationContext, + lifecycle_session: Option<&str>, + adapter_id: &str, + resource_digest: &str, +) -> ResourceGrantKey { + let public_session = context + .public_session() + .or(lifecycle_session) + .unwrap_or("compatibility"); + ResourceGrantKey { + runtime_scope: context.runtime_scope_key(), + public_session: context.runtime_session_key(public_session), + transport_session: context + .transport_session() + .unwrap_or(public_session) + .to_owned(), + adapter_id: adapter_id.to_owned(), + resource_digest: resource_digest.to_owned(), + } +} + +fn digest_resource(adapter_id: &str, resource: &Value) -> String { + let canonical = serde_json::json!({ + "adapter_id": adapter_id, + "resource": resource, + }); + let bytes = serde_json::to_vec(&canonical).expect("serialize protected resource"); + format!("{:x}", Sha256::digest(bytes)) +} + fn now_unix_ms() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -323,7 +555,6 @@ fn digest_request(request: &ConsentRequest) -> String { #[cfg(test)] mod tests { use super::*; - use std::sync::Mutex; struct FakeProvider { action: ConsentAction, @@ -399,6 +630,134 @@ mod tests { ) } + fn authorization_context( + mode: PermissionMode, + ) -> Arc { + let ceiling = crate::session_authorization::SessionModeCeiling::for_trusted_sessions( + [mode], + mode == PermissionMode::Unrestricted, + Duration::from_secs(60), + Duration::from_secs(30), + ) + .unwrap(); + crate::session_authorization::SessionAuthorizationRegistry::with_ceiling(ceiling) + .compatibility_context(mode, None) + .unwrap() + } + + #[test] + fn provider_identity_is_runtime_scoped() { + let with_provider = crate::tool::ToolRegistry::new_with_protected_consent_provider(Some( + provider(ConsentAction::Accept), + )); + let without_provider = crate::tool::ToolRegistry::new(); + + assert_eq!( + with_provider.authorization_status_json()["protected_consent_collector"], + "test.protected-provider" + ); + assert_eq!( + without_provider.authorization_status_json()["protected_consent_collector"], + serde_json::Value::Null + ); + } + + #[tokio::test] + async fn exact_resource_grants_are_reused_and_scope_changes_reprompt() { + let provider = provider(ConsentAction::Accept); + let broker = Arc::new(ApprovalBroker::new(Some(provider.clone()))); + let grants = ProtectedResourceGrants::new(broker); + let context = authorization_context(PermissionMode::Standard); + + let first = grants + .authorize( + &context, + "private_observation", + RiskClass::R2, + Some("public-a"), + serde_json::json!({"pid": 42, "window_id": 7}), + "Observe app window 7", + Duration::from_secs(30), + Duration::from_secs(60), + ) + .await + .unwrap() + .unwrap(); + let reused = grants + .authorize( + &context, + "private_observation", + RiskClass::R2, + Some("public-a"), + serde_json::json!({"pid": 42, "window_id": 7}), + "Observe app window 7", + Duration::from_secs(30), + Duration::from_secs(60), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(first.protected.id, reused.protected.id); + assert_eq!(provider.requests.load(Ordering::SeqCst), 1); + + grants + .authorize( + &context, + "private_observation", + RiskClass::R2, + Some("public-a"), + serde_json::json!({"pid": 42, "window_id": 8}), + "Observe app window 8", + Duration::from_secs(30), + Duration::from_secs(60), + ) + .await + .unwrap(); + assert_eq!(provider.requests.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn revocation_is_immediate_and_unrestricted_skips_the_broker() { + let provider = provider(ConsentAction::Accept); + let broker = Arc::new(ApprovalBroker::new(Some(provider.clone()))); + let grants = ProtectedResourceGrants::new(broker); + let standard = authorization_context(PermissionMode::Standard); + let live = grants + .authorize( + &standard, + "desktop_input", + RiskClass::R1, + Some("public-a"), + serde_json::json!({"pid": 42, "window_id": 7}), + "Control app window 7", + Duration::from_secs(30), + Duration::from_secs(60), + ) + .await + .unwrap() + .unwrap(); + let revoked = grants.revoke_session(&standard.runtime_session_key("public-a")); + assert_eq!(revoked.len(), 1); + assert!(!live.is_live()); + + let unrestricted = authorization_context(PermissionMode::Unrestricted); + assert!(grants + .authorize( + &unrestricted, + "desktop_input", + RiskClass::R1, + Some("public-b"), + serde_json::json!({"pid": 9, "window_id": 2}), + "Control app window 2", + Duration::from_secs(30), + Duration::from_secs(60), + ) + .await + .unwrap() + .is_none()); + assert_eq!(provider.requests.load(Ordering::SeqCst), 1); + } + #[tokio::test] async fn exact_accept_activates_a_revocable_indicator() { let provider = provider(ConsentAction::Accept); diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/session_authorization.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/session_authorization.rs index 83673716be..d97783a3ac 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/session_authorization.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/session_authorization.rs @@ -232,6 +232,21 @@ impl EffectiveAuthorizationContext { self.public_session.as_deref() } + #[doc(hidden)] + pub fn transport_session(&self) -> Option<&str> { + self.transport_session.as_deref() + } + + #[doc(hidden)] + pub fn user_policy_sha256(&self) -> Option<&str> { + self.user_policy_sha256.as_deref() + } + + #[doc(hidden)] + pub fn managed_policy_sha256(&self) -> Option<&str> { + self.managed_policy_sha256.as_deref() + } + pub fn is_expired(&self) -> bool { if self.revoked.load(Ordering::Acquire) { return true; diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/tool.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/tool.rs index 04151d8aef..bf78e822e9 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/tool.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/tool.rs @@ -374,21 +374,77 @@ pub struct ToolRegistry { session_end_hooks: Vec, cursor_outcome_readers: Vec, runtime_cleanups: Vec, + /// Runtime-owned protected-consent broker shared by every resource + /// adapter. Keeping it at the canonical dispatch boundary prevents + /// browser, desktop, and file adapters from growing independent provider + /// identities or grant lifecycles. + approval_broker: Arc, + protected_resource_grants: Arc, } impl ToolRegistry { pub fn new() -> Self { + Self::new_with_protected_consent_provider(None) + } + + /// Construct a registry with a provider installed by a trusted embedding + /// host. Public tool calls and transport metadata cannot replace it. + pub fn new_with_protected_consent_provider( + provider: Option>, + ) -> Self { + let approval_broker = Arc::new(crate::consent::ApprovalBroker::new(provider)); + let protected_resource_grants = Arc::new(crate::consent::ProtectedResourceGrants::new( + approval_broker.clone(), + )); + let weak_grants = Arc::downgrade(&protected_resource_grants); + let session_end_hook = + crate::session::register_scoped_session_end_hook(move |session_id| { + let Some(grants) = weak_grants.upgrade() else { + return; + }; + let revoked = grants.revoke_session(session_id); + if revoked.is_empty() { + return; + } + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + for grant in revoked { + grants.broker().revoke(&grant).await; + } + }); + } + }); Self { tools: HashMap::new(), order: Vec::new(), recording: Arc::new(RecordingSession::new()), replay_registry: Arc::new(std::sync::Mutex::new(std::sync::Weak::new())), - session_end_hooks: Vec::new(), + session_end_hooks: vec![session_end_hook], cursor_outcome_readers: Vec::new(), runtime_cleanups: Vec::new(), + approval_broker, + protected_resource_grants, } } + /// Return the runtime-owned broker for adapter construction. + /// + /// This is intentionally not exposed through MCP/CLI arguments. Platform + /// registries pass the clone to resource adapters while assembling one + /// trusted runtime. + pub fn approval_broker(&self) -> Arc { + self.approval_broker.clone() + } + + pub fn protected_resource_grants(&self) -> Arc { + self.protected_resource_grants.clone() + } + + /// Content-free authorization status for this exact runtime. + pub fn authorization_status_json(&self) -> Value { + crate::authorization::status_json_with_provider(self.approval_broker.provider_id()) + } + pub fn register(&mut self, tool: Box) { let name = tool.def().name.clone(); self.order.push(name.clone()); diff --git a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs index 6fb4d9b6a3..b78bf32e04 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs @@ -7119,9 +7119,10 @@ pub fn build_registry(compat: bool) -> ToolRegistry { r.register(Box::new(cua_driver_core::page::PageTool::new(Arc::new( super::page::LinuxPageBackend::new(), )))); - let browser_engine = cua_driver_core::browser::BrowserEngine::new(Arc::new( - crate::browser_platform::LinuxBrowserPlatform, - )); + let browser_engine = cua_driver_core::browser::BrowserEngine::new_with_approval_broker( + Arc::new(crate::browser_platform::LinuxBrowserPlatform), + r.approval_broker(), + ); cua_driver_core::browser::register_browser_tools(&browser_engine, &mut r); r.register_recording_tools(); r.register_session_tools(); diff --git a/libs/cua-driver/rust/crates/platform-macos/src/tools/mod.rs b/libs/cua-driver/rust/crates/platform-macos/src/tools/mod.rs index 8a8c09b0c9..2162dcb381 100644 --- a/libs/cua-driver/rust/crates/platform-macos/src/tools/mod.rs +++ b/libs/cua-driver/rust/crates/platform-macos/src/tools/mod.rs @@ -682,9 +682,12 @@ pub fn register_all( registry.register(Box::new(cua_driver_core::page::PageTool::new(Arc::new( page::MacOsPageBackend::new(state.clone()), )))); - let browser_engine = cua_driver_core::browser::BrowserEngine::new(Arc::new( - crate::browser::MacOsBrowserPlatform::new(state.cursor_registry.clone()), - )); + let browser_engine = cua_driver_core::browser::BrowserEngine::new_with_approval_broker( + Arc::new(crate::browser::MacOsBrowserPlatform::new( + state.cursor_registry.clone(), + )), + registry.approval_broker(), + ); cua_driver_core::browser::register_browser_tools(&browser_engine, registry); // Recording / replay + session-lifecycle tools are platform-independent. registry.register_recording_tools(); diff --git a/libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs b/libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs index 95d0d5318c..348df2f18b 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs @@ -8715,9 +8715,12 @@ pub fn build_registry(compat: bool) -> ToolRegistry { r.register(Box::new(cua_driver_core::page::PageTool::new( std::sync::Arc::new(super::page::WindowsPageBackend::new()), ))); - let browser_engine = cua_driver_core::browser::BrowserEngine::new(std::sync::Arc::new( - crate::browser_platform::WindowsBrowserPlatform::new(state.cursor_registry.clone()), - )); + let browser_engine = cua_driver_core::browser::BrowserEngine::new_with_approval_broker( + std::sync::Arc::new(crate::browser_platform::WindowsBrowserPlatform::new( + state.cursor_registry.clone(), + )), + r.approval_broker(), + ); cua_driver_core::browser::register_browser_tools(&browser_engine, &mut r); r.register_recording_tools(); r.register_session_tools();