Skip to main content

adk_computer_use/contracts/
target.rs

1//! Target evidence and value-free accessibility sensitivity contracts.
2//!
3//! These types bind a proposed action to a *fresh* desktop observation and
4//! carry only digests and structured signals — never raw field values — so the
5//! wire payload cannot leak sensitive content across the ADK/runtime boundary.
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::HashSet;
10
11/// Evidence binding an action to a fresh desktop observation.
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13#[serde(rename_all = "camelCase")]
14pub struct TargetEvidence {
15    /// Platform identifier for the observed target (e.g. `darwin`, `windows`).
16    pub platform: String,
17    /// Application/bundle identifier of the observed target.
18    pub app_id: String,
19    /// Process identifier of the observed target, when known.
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub pid: Option<u32>,
22    /// Platform-specific window identifier, when known.
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub window_id: Option<Value>,
25    /// Digest of the window title (never the raw title).
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub window_title_digest: Option<String>,
28    /// Display identifier hosting the target, when known.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub display_id: Option<String>,
31    /// Accessibility role of the target element, when known.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub role: Option<String>,
34    /// Digest of the element label (never the raw label).
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub label_digest: Option<String>,
37    /// Bounding box of the target element, when known.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub bounds: Option<Bounds>,
40    /// Identifier of the observation frame this evidence came from.
41    pub observation_id: String,
42    /// Hash of the screenshot backing this observation, when captured.
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub screenshot_hash: Option<String>,
45    /// Revision of the UI tree backing this observation, when captured.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub ui_tree_revision: Option<String>,
48    /// Observation confidence in `0.0..=1.0`.
49    pub confidence: f64,
50    /// RFC 3339 timestamp of the observation.
51    pub captured_at: String,
52}
53
54/// Axis-aligned bounding box in display coordinates.
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub struct Bounds {
58    /// Left edge in display coordinates.
59    pub x: f64,
60    /// Top edge in display coordinates.
61    pub y: f64,
62    /// Width in display units.
63    pub width: f64,
64    /// Height in display units.
65    pub height: f64,
66}
67
68/// Conclusion of an accessibility-based target sensitivity check.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum TargetSensitivityAssessment {
72    /// The target holds sensitive content (e.g. a password field).
73    Sensitive,
74    /// The target is confirmed non-sensitive.
75    NonSensitive,
76    /// Sensitivity could not be determined.
77    Unknown,
78}
79
80/// Source of a [`TargetSensitivityEvidence`] assessment.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum TargetSensitivitySource {
84    /// Derived from platform accessibility APIs.
85    Accessibility,
86    /// Native sensitivity signals were unavailable.
87    Unavailable,
88}
89
90/// A single value-free signal contributing to a sensitivity assessment.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum TargetSensitivitySignal {
94    /// Element reports a secure accessibility role.
95    SecureRole,
96    /// Element reports a secure accessibility subrole.
97    SecureSubrole,
98    /// Element is marked as protected content.
99    ProtectedContent,
100    /// UI Automation reports the element as a password field.
101    UiaIsPassword,
102    /// Element label matched a sensitive pattern.
103    SensitiveLabel,
104    /// Multiple candidate elements matched ambiguously.
105    AmbiguousMatch,
106    /// The referenced element was not found.
107    ElementNotFound,
108    /// Inspection raised an error.
109    InspectionError,
110    /// The field was invalid for inspection.
111    InvalidField,
112    /// Native sensitivity signals were unavailable.
113    NativeSignalUnavailable,
114}
115
116/// Value-free native accessibility evidence used for action risk and revalidation.
117///
118/// Construct with [`TargetSensitivityEvidence::try_new`]; the constructor and the
119/// deserializer enforce the same invariants (bounded/unique signals, conclusive
120/// assessments require accessibility evidence for a checked field).
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(try_from = "RawTargetSensitivityEvidence", into = "RawTargetSensitivityEvidence")]
123pub struct TargetSensitivityEvidence {
124    assessment: TargetSensitivityAssessment,
125    source: TargetSensitivitySource,
126    signals: Vec<TargetSensitivitySignal>,
127    fields_checked: u32,
128    observed_at: String,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase", deny_unknown_fields)]
133struct RawTargetSensitivityEvidence {
134    assessment: TargetSensitivityAssessment,
135    source: TargetSensitivitySource,
136    signals: Vec<TargetSensitivitySignal>,
137    fields_checked: u32,
138    observed_at: String,
139}
140
141impl TargetSensitivityEvidence {
142    /// Build validated sensitivity evidence.
143    ///
144    /// # Errors
145    ///
146    /// Returns a message describing the violated invariant when signals exceed
147    /// 10, contain duplicates, `fields_checked` exceeds 100, a conclusive
148    /// assessment lacks accessibility evidence for a checked field, a
149    /// `Sensitive` assessment has no signals, or `observed_at` is blank.
150    ///
151    /// # Example
152    ///
153    /// ```
154    /// use adk_computer_use::{
155    ///     TargetSensitivityAssessment, TargetSensitivityEvidence, TargetSensitivitySignal,
156    ///     TargetSensitivitySource,
157    /// };
158    ///
159    /// let evidence = TargetSensitivityEvidence::try_new(
160    ///     TargetSensitivityAssessment::Sensitive,
161    ///     TargetSensitivitySource::Accessibility,
162    ///     vec![TargetSensitivitySignal::UiaIsPassword],
163    ///     1,
164    ///     "2026-07-13T12:00:00Z",
165    /// )
166    /// .unwrap();
167    /// assert_eq!(evidence.fields_checked(), 1);
168    /// ```
169    pub fn try_new(
170        assessment: TargetSensitivityAssessment,
171        source: TargetSensitivitySource,
172        signals: Vec<TargetSensitivitySignal>,
173        fields_checked: u32,
174        observed_at: impl Into<String>,
175    ) -> Result<Self, String> {
176        RawTargetSensitivityEvidence {
177            assessment,
178            source,
179            signals,
180            fields_checked,
181            observed_at: observed_at.into(),
182        }
183        .try_into()
184    }
185
186    /// The conclusion of the sensitivity check.
187    pub fn assessment(&self) -> TargetSensitivityAssessment {
188        self.assessment
189    }
190
191    /// The source of the assessment.
192    pub fn source(&self) -> TargetSensitivitySource {
193        self.source
194    }
195
196    /// The distinct signals that contributed to the assessment.
197    pub fn signals(&self) -> &[TargetSensitivitySignal] {
198        &self.signals
199    }
200
201    /// The number of fields inspected during the check.
202    pub fn fields_checked(&self) -> u32 {
203        self.fields_checked
204    }
205
206    /// RFC 3339 timestamp of the observation.
207    pub fn observed_at(&self) -> &str {
208        &self.observed_at
209    }
210}
211
212impl TryFrom<RawTargetSensitivityEvidence> for TargetSensitivityEvidence {
213    type Error = String;
214
215    fn try_from(raw: RawTargetSensitivityEvidence) -> Result<Self, Self::Error> {
216        if raw.signals.len() > 10 {
217            return Err("target sensitivity supports at most 10 signals".into());
218        }
219        if raw.signals.iter().copied().collect::<HashSet<_>>().len() != raw.signals.len() {
220            return Err("target sensitivity signals must be unique".into());
221        }
222        if raw.fields_checked > 100 {
223            return Err("target sensitivity supports at most 100 checked fields".into());
224        }
225        if matches!(
226            raw.assessment,
227            TargetSensitivityAssessment::Sensitive | TargetSensitivityAssessment::NonSensitive
228        ) && (raw.source != TargetSensitivitySource::Accessibility || raw.fields_checked == 0)
229        {
230            return Err(
231                "conclusive target sensitivity requires accessibility evidence for a checked field"
232                    .into(),
233            );
234        }
235        if raw.assessment == TargetSensitivityAssessment::Sensitive && raw.signals.is_empty() {
236            return Err("sensitive target evidence requires at least one signal".into());
237        }
238        if raw.observed_at.trim().is_empty() {
239            return Err("target sensitivity observedAt must not be empty".into());
240        }
241        Ok(Self {
242            assessment: raw.assessment,
243            source: raw.source,
244            signals: raw.signals,
245            fields_checked: raw.fields_checked,
246            observed_at: raw.observed_at,
247        })
248    }
249}
250
251impl From<TargetSensitivityEvidence> for RawTargetSensitivityEvidence {
252    fn from(value: TargetSensitivityEvidence) -> Self {
253        Self {
254            assessment: value.assessment,
255            source: value.source,
256            signals: value.signals,
257            fields_checked: value.fields_checked,
258            observed_at: value.observed_at,
259        }
260    }
261}