diff --git a/crates/webcodex-computer/src/macos_live_tests.rs b/crates/webcodex-computer/src/macos_live_tests.rs index 2f4ff4cd6..86ae202a0 100644 --- a/crates/webcodex-computer/src/macos_live_tests.rs +++ b/crates/webcodex-computer/src/macos_live_tests.rs @@ -341,6 +341,31 @@ fn computer_macos_accessibility_permission_live_smoke() { assert_eq!(status["trusted"], true); } +#[test] +#[ignore = "requires a visible Calculator window and live macOS Accessibility permission"] +fn computer_macos_accessibility_calculator_live_regression() { + let candidates = platform::list_windows(MAX_WINDOWS).expect("list live macOS windows"); + let candidate = candidates + .into_iter() + .find(|candidate| { + let name = candidate.application.to_lowercase(); + name.contains("calculator") || name.contains("kalkula") + }) + .expect("Calculator must expose a visible native window"); + let record = surface_record(candidate); + // A depth-zero failure isolates the window fingerprint from child traversal. + for depth in 0..=3 { + let tree = platform::accessibility_tree("surface_calculator_live", &record, depth, 64) + .unwrap_or_else(|error| { + panic!("Calculator AX read failed at max_depth={depth}: {error}") + }); + assert!( + tree.output["node_count"].as_u64().unwrap_or(0) > 0, + "Calculator tree must include its root at depth {depth}" + ); + } +} + #[test] #[ignore = "requires live Microsoft Edge window and macOS Accessibility permission"] fn computer_macos_accessibility_edge_live_smoke() { diff --git a/crates/webcodex-computer/src/macos_observation_tests.rs b/crates/webcodex-computer/src/macos_observation_tests.rs index 16be537dd..18a197c7b 100644 --- a/crates/webcodex-computer/src/macos_observation_tests.rs +++ b/crates/webcodex-computer/src/macos_observation_tests.rs @@ -108,6 +108,51 @@ fn ax_observation_scoped_seed_checks_secure_ancestry_without_protected_flag() { } } +#[test] +fn ax_unverified_subrole_redacts_text_blocks_input_and_cannot_attest_identity() { + let unverified = ElementFingerprint { + role: "AXTextField".into(), + subrole: None, + identifier: None, + title: None, + description: None, + placeholder: None, + protected: true, + #[cfg(windows)] + native_runtime_id: vec![1, 2], + }; + let observed = finish_ax_fingerprint(unverified, |_| { + panic!("unknown secure subrole must prevent every text attribute read") + }) + .unwrap(); + assert_redacted(&observed); + assert!(!observed.has_positive_evidence()); + + let element = ElementRecord { + surface_id: "surface_unverified".into(), + path: vec![0], + lineage: vec![fingerprint("AXWindow", None, false), observed], + }; + assert!(element.contains_protected_content()); + assert!(validate_element_state_target(&element).is_err()); + assert!(validate_text_input_target(&element).is_err()); + + let descendant = finish_ax_fingerprint( + fingerprint("AXGroup", None, element.contains_protected_content()), + |_| panic!("descendant of uncertain subrole must not read text"), + ) + .unwrap(); + assert_redacted(&descendant); + + // A separate sibling with verified metadata remains readable. + let sibling = finish_ax_fingerprint(fingerprint("AXButton", None, false), |attribute| { + Ok((attribute == "AXTitle").then_some("7".into())) + }) + .unwrap(); + assert_eq!(sibling.title.as_deref(), Some("7")); + assert!(!sibling.protected); +} + #[test] fn ax_observation_nonsecure_text_and_provider_errors_are_preserved() { let mut read = Vec::new(); diff --git a/crates/webcodex-computer/src/platform/macos/accessibility.rs b/crates/webcodex-computer/src/platform/macos/accessibility.rs index 06c4335ac..438f9417e 100644 --- a/crates/webcodex-computer/src/platform/macos/accessibility.rs +++ b/crates/webcodex-computer/src/platform/macos/accessibility.rs @@ -19,6 +19,47 @@ fn accessibility_error(operation: &str, error: AXError) -> String { } } +#[cfg(target_os = "macos")] +fn accessibility_attribute_error( + operation: &str, + attribute: &'static str, + error: AXError, +) -> String { + let message = accessibility_error(operation, error); + if error == AXError::APIDisabled { + return message; + } + // The name is a fixed native AX attribute, not its value or window content. + // It identifies the failing provider boundary without exposing UI data. + format!("{message} (attribute={attribute})") +} + +#[cfg(all(test, target_os = "macos"))] +mod accessibility_attribute_error_tests { + use super::*; + + #[test] + fn ax_failure_identifies_fixed_attribute_without_changing_permission_class() { + let error = accessibility_attribute_error( + "AXUIElementCopyAttributeValue", + "AXProtectedContent", + AXError::Failure, + ); + assert_eq!( + error, + "accessibility_failed: AXUIElementCopyAttributeValue failed with AXError(-25200) (attribute=AXProtectedContent)" + ); + assert_eq!( + accessibility_attribute_error( + "AXUIElementCopyAttributeValue", + "AXProtectedContent", + AXError::APIDisabled, + ), + "permission_denied: macOS Accessibility permission is not granted" + ); + } +} + #[cfg(target_os = "macos")] pub(super) fn checked_surface_pid(surface: &SurfaceRecord) -> Result { libc::pid_t::try_from(surface.pid) @@ -148,9 +189,9 @@ pub(super) fn prepare_ax_call( fn optional_ax_value( deadline: &AxObservationDeadline, element: &AXUIElement, - attribute: &'static str, + attribute_name: &'static str, ) -> Result>, String> { - let attribute = CFString::from_static_str(attribute); + let attribute = CFString::from_static_str(attribute_name); let mut raw: *const CFType = std::ptr::null(); prepare_ax_call(deadline, element)?; let error = unsafe { element.copy_attribute_value(&attribute, NonNull::from(&mut raw)) }; @@ -163,7 +204,11 @@ fn optional_ax_value( Ok(Some(unsafe { CFRetained::from_raw(raw) })) } AXError::AttributeUnsupported | AXError::NoValue => Ok(None), - error => Err(accessibility_error("AXUIElementCopyAttributeValue", error)), + error => Err(accessibility_attribute_error( + "AXUIElementCopyAttributeValue", + attribute_name, + error, + )), } } @@ -182,6 +227,111 @@ pub(super) fn optional_ax_string( .map(|value| bounded_text(&value.to_string()))) } +#[cfg(target_os = "macos")] +fn handle_ax_subrole_failure(error: AXError) -> Result { + if error == AXError::Failure { + // A provider-level failure does not prove that the subrole is absent. + // It may hide a secure text subrole, so the subtree must be protected. + Ok(true) + } else { + Err(accessibility_attribute_error( + "AXUIElementCopyAttributeValue", + "AXSubrole", + error, + )) + } +} + +#[cfg(target_os = "macos")] +fn classify_ax_subrole_value(value: CFRetained) -> (Option, bool) { + match value.downcast::() { + Ok(subrole) => (Some(bounded_text(&subrole.to_string())), false), + // A successful AX read with an unexpected value type does not prove + // that the element is non-secure. Preserve fail-closed protection. + Err(_) => (None, true), + } +} + +#[cfg(target_os = "macos")] +fn optional_ax_subrole( + deadline: &AxObservationDeadline, + element: &AXUIElement, +) -> Result<(Option, bool), String> { + let attribute = CFString::from_static_str("AXSubrole"); + let mut raw: *const CFType = std::ptr::null(); + prepare_ax_call(deadline, element)?; + let error = unsafe { element.copy_attribute_value(&attribute, NonNull::from(&mut raw)) }; + deadline.ensure_remaining()?; + match error { + AXError::Success => { + let raw = NonNull::new(raw.cast_mut()).ok_or_else(|| { + "accessibility_failed: AX attribute succeeded with null value".to_string() + })?; + let value: CFRetained = unsafe { CFRetained::from_raw(raw) }; + Ok(classify_ax_subrole_value(value)) + } + AXError::AttributeUnsupported | AXError::NoValue => Ok((None, false)), + error => { + handle_ax_subrole_failure(error)?; + Ok((None, true)) + } + } +} + +#[cfg(all(test, target_os = "macos"))] +mod ax_subrole_failure_tests { + use super::*; + + #[test] + fn unexpected_successful_subrole_value_type_remains_protected() { + let valid: CFRetained = CFString::from_static_str("AXSecureTextField").into(); + assert_eq!( + classify_ax_subrole_value(valid), + (Some("AXSecureTextField".into()), false) + ); + + let invalid: CFRetained = CFBoolean::new(true).into(); + let (subrole, unverified) = classify_ax_subrole_value(invalid); + assert!(subrole.is_none()); + assert!(unverified); + + let fingerprint = finish_ax_fingerprint( + ElementFingerprint { + role: "AXTextField".into(), + subrole, + identifier: None, + title: None, + description: None, + placeholder: None, + protected: unverified, + }, + |_| panic!("unverified subrole must not permit text disclosure"), + ) + .expect("protected fingerprint"); + assert!(fingerprint.protected); + assert!(!fingerprint.has_positive_evidence()); + assert_eq!( + ax_observed_value(&fingerprint, || panic!("must not read AXValue")) + .expect("protected AXValue"), + None + ); + } + + #[test] + fn generic_subrole_failure_is_protected_but_other_errors_fail_closed() { + assert_eq!(handle_ax_subrole_failure(AXError::Failure), Ok(true)); + for error in [AXError::CannotComplete, AXError::InvalidUIElement] { + let message = handle_ax_subrole_failure(error).unwrap_err(); + assert!(message.starts_with("accessibility_failed:"), "{message}"); + assert!(message.contains("attribute=AXSubrole"), "{message}"); + } + assert_eq!( + handle_ax_subrole_failure(AXError::APIDisabled).unwrap_err(), + "permission_denied: macOS Accessibility permission is not granted" + ); + } +} + #[cfg(target_os = "macos")] fn element_fingerprint( deadline: &AxObservationDeadline, @@ -190,13 +340,22 @@ fn element_fingerprint( ) -> Result { let role = optional_ax_string(deadline, element, "AXRole")? .ok_or_else(|| "accessibility_failed: AX element is missing a string role".to_string())?; - let protected = inherited_protected - || optional_ax_bool(deadline, element, "AXProtectedContent")?.unwrap_or(false); + let native_protected = + optional_ax_bool(deadline, element, "AXProtectedContent")?.unwrap_or(false); + let (subrole, unverified_subrole) = optional_ax_subrole(deadline, element)?; + let protected = inherited_protected || native_protected || unverified_subrole; + // Do not assign identifying metadata to an element whose secure subrole + // could not be established. Its descendants inherit the protective marker. + let identifier = if unverified_subrole { + None + } else { + optional_ax_string(deadline, element, "AXIdentifier")? + }; finish_ax_fingerprint( ElementFingerprint { role, - subrole: optional_ax_string(deadline, element, "AXSubrole")?, - identifier: optional_ax_string(deadline, element, "AXIdentifier")?, + subrole, + identifier, title: None, description: None, placeholder: None,