Skip to main content

adk_computer_use/runtime/
binding.rs

1//! Binds every security-relevant MCP response back to what was actually asked for.
2//!
3//! Typed deserialization proves shape, not provenance. `ControlLease`, `TargetReservation`,
4//! and `ExecutionReceipt` have no invariant-enforcing constructor, so a well-formed object
5//! belonging to another session, principal, or action deserializes cleanly and was accepted
6//! into graph state. The external runtime stays authoritative; this is the local check that
7//! makes a stale, confused, or mismatched response fail here rather than propagate.
8//!
9//! Each validator compares one response against the envelope that requested it and reports
10//! the first mismatch by field name, so a rejection says what did not line up.
11
12use crate::contracts::{ActionEnvelope, ControlLease, ExecutionReceipt, TargetReservation};
13use crate::error::ComputerUseError;
14
15/// Verifies that an action envelope is inside its runtime-declared validity window.
16///
17/// The runtime owns the validity duration. ADK only enforces that both timestamps are
18/// readable, that the interval is ordered, and that execution happens before `expires_at`.
19/// This check belongs immediately before mutation because an approval interrupt or lease
20/// acquisition can consume the rest of an otherwise-valid preview window.
21///
22/// # Errors
23///
24/// Returns [`ComputerUseError::IdentityMismatch`] when either timestamp is unreadable,
25/// `expires_at` is not later than `proposed_at`, or the envelope has expired.
26pub fn validate_envelope_freshness(envelope: &ActionEnvelope) -> Result<(), ComputerUseError> {
27    let proposed_at =
28        chrono::DateTime::parse_from_rfc3339(&envelope.proposed_at).map_err(|error| {
29            ComputerUseError::IdentityMismatch(format!(
30                "action envelope {} has an unreadable proposed_at {:?}: {error}",
31                envelope.action_id, envelope.proposed_at
32            ))
33        })?;
34    let expires_at =
35        chrono::DateTime::parse_from_rfc3339(&envelope.expires_at).map_err(|error| {
36            ComputerUseError::IdentityMismatch(format!(
37                "action envelope {} has an unreadable expires_at {:?}: {error}",
38                envelope.action_id, envelope.expires_at
39            ))
40        })?;
41
42    if expires_at <= proposed_at {
43        return Err(ComputerUseError::IdentityMismatch(format!(
44            "action envelope {} has a non-positive validity window: proposed_at is {} and \
45             expires_at is {}",
46            envelope.action_id, envelope.proposed_at, envelope.expires_at
47        )));
48    }
49    if expires_at <= chrono::Utc::now() {
50        return Err(ComputerUseError::IdentityMismatch(format!(
51            "action envelope {} expired at {}",
52            envelope.action_id, envelope.expires_at
53        )));
54    }
55
56    Ok(())
57}
58
59/// Reports a mismatch between what was requested and what came back.
60fn mismatch(object: &str, field: &str, expected: &str, actual: &str) -> ComputerUseError {
61    ComputerUseError::IdentityMismatch(format!(
62        "{object} returned by the computer-use runtime is not bound to this request: {field} \
63         is {actual:?}, expected {expected:?}. The response was rejected rather than stored."
64    ))
65}
66
67/// Compares two optional values, treating a returned `None` as acceptable.
68///
69/// The wire contract makes `agent_id` and similar fields optional, so absence is under-
70/// specification rather than contradiction. A *present and different* value is a mismatch.
71fn check_optional(
72    object: &str,
73    field: &str,
74    expected: Option<&str>,
75    actual: Option<&str>,
76) -> Result<(), ComputerUseError> {
77    match (expected, actual) {
78        (Some(expected), Some(actual)) if expected != actual => {
79            Err(mismatch(object, field, expected, actual))
80        }
81        _ => Ok(()),
82    }
83}
84
85/// Verifies a lease belongs to this session, principal, agent, and mode, and is usable.
86///
87/// # Errors
88///
89/// Returns [`ComputerUseError::IdentityMismatch`] when any bound field disagrees with
90/// `envelope`, when the lease is not active, or when its action budget is exhausted.
91///
92/// # Example
93///
94/// ```rust
95/// use adk_computer_use::runtime::binding::validate_lease;
96/// use adk_computer_use::{ActionEnvelope, ControlLease};
97///
98/// # fn envelope() -> ActionEnvelope { unimplemented!() }
99/// # fn lease_for_another_session() -> ControlLease { unimplemented!() }
100/// # fn check() -> Result<(), Box<dyn std::error::Error>> {
101/// let envelope = envelope();
102/// let lease = lease_for_another_session();
103///
104/// // A well-formed lease bound to a different session is refused here rather than
105/// // entering graph state.
106/// assert!(validate_lease(&lease, &envelope).is_err());
107/// # Ok(())
108/// # }
109/// ```
110pub fn validate_lease(
111    lease: &ControlLease,
112    envelope: &ActionEnvelope,
113) -> Result<(), ComputerUseError> {
114    const OBJECT: &str = "control lease";
115
116    if lease.session_id != envelope.session_id {
117        return Err(mismatch(OBJECT, "session_id", &envelope.session_id, &lease.session_id));
118    }
119    if lease.principal_id != envelope.principal_id {
120        return Err(mismatch(OBJECT, "principal_id", &envelope.principal_id, &lease.principal_id));
121    }
122    check_optional(OBJECT, "agent_id", envelope.agent_id.as_deref(), lease.agent_id.as_deref())?;
123
124    if lease.execution_mode != envelope.requested_mode {
125        return Err(mismatch(
126            OBJECT,
127            "execution_mode",
128            &format!("{:?}", envelope.requested_mode),
129            &format!("{:?}", lease.execution_mode),
130        ));
131    }
132
133    // A lease that is not active grants nothing, and a zero budget cannot cover the action
134    // it was acquired for. Both are usable-looking objects that must not proceed.
135    if !lease.state.eq_ignore_ascii_case("active") {
136        return Err(ComputerUseError::IdentityMismatch(format!(
137            "control lease {} is in state {:?}, not active, so it authorizes nothing",
138            lease.lease_id, lease.state
139        )));
140    }
141    // Remaining budget, not total. Checking `action_budget == 0` accepted a lease whose budget
142    // was fully consumed — `action_budget: 1, actions_used: 1` passed while authorizing nothing.
143    if lease.actions_used >= lease.action_budget {
144        return Err(ComputerUseError::IdentityMismatch(format!(
145            "control lease {} has no remaining action budget: {} of {} used",
146            lease.lease_id, lease.actions_used, lease.action_budget
147        )));
148    }
149
150    // An expired lease is a well-formed object that authorizes nothing. Rejecting an
151    // unparseable timestamp is deliberate: a lease whose expiry cannot be read is a lease
152    // whose validity cannot be established.
153    match chrono::DateTime::parse_from_rfc3339(&lease.expires_at) {
154        Ok(expires_at) => {
155            if expires_at <= chrono::Utc::now() {
156                return Err(ComputerUseError::IdentityMismatch(format!(
157                    "control lease {} expired at {}",
158                    lease.lease_id, lease.expires_at
159                )));
160            }
161        }
162        Err(e) => {
163            return Err(ComputerUseError::IdentityMismatch(format!(
164                "control lease {} has an unreadable expiry {:?}: {e}",
165                lease.lease_id, lease.expires_at
166            )));
167        }
168    }
169
170    // Target boundaries. A lease scoped to one application must not authorize an action against
171    // another, which is the whole point of scoping it.
172    if let Some(target) = &envelope.target {
173        if !lease.boundaries.app_ids.is_empty()
174            && !lease.boundaries.app_ids.contains(&target.app_id)
175        {
176            return Err(mismatch(
177                OBJECT,
178                "boundaries.app_ids",
179                &target.app_id,
180                &format!("{:?}", lease.boundaries.app_ids),
181            ));
182        }
183
184        if let Some(window_id) = &target.window_id
185            && !lease.boundaries.window_ids.is_empty()
186            && !lease.boundaries.window_ids.contains(window_id)
187        {
188            return Err(mismatch(
189                OBJECT,
190                "boundaries.window_ids",
191                &format!("{window_id}"),
192                &format!("{:?}", lease.boundaries.window_ids),
193            ));
194        }
195    }
196
197    Ok(())
198}
199
200/// Verifies a reservation belongs to this action and is active, current, and target-bound.
201///
202/// # Errors
203///
204/// Returns [`ComputerUseError::IdentityMismatch`] when any bound field disagrees with
205/// `envelope`, the reservation is not active, its expiry cannot be established, or it has
206/// expired.
207pub fn validate_reservation(
208    reservation: &TargetReservation,
209    envelope: &ActionEnvelope,
210) -> Result<(), ComputerUseError> {
211    const OBJECT: &str = "target reservation";
212
213    if reservation.session_id != envelope.session_id {
214        return Err(mismatch(OBJECT, "session_id", &envelope.session_id, &reservation.session_id));
215    }
216    if reservation.principal_id != envelope.principal_id {
217        return Err(mismatch(
218            OBJECT,
219            "principal_id",
220            &envelope.principal_id,
221            &reservation.principal_id,
222        ));
223    }
224    check_optional(
225        OBJECT,
226        "agent_id",
227        envelope.agent_id.as_deref(),
228        reservation.agent_id.as_deref(),
229    )?;
230    check_optional(
231        OBJECT,
232        "execution_group_id",
233        envelope.execution_group_id.as_deref(),
234        reservation.execution_group_id.as_deref(),
235    )?;
236
237    if reservation.intent_id != envelope.action_id {
238        return Err(mismatch(OBJECT, "intent_id", &envelope.action_id, &reservation.intent_id));
239    }
240    if !reservation.state.eq_ignore_ascii_case("active") {
241        return Err(ComputerUseError::IdentityMismatch(format!(
242            "target reservation {} is in state {:?}, not active",
243            reservation.reservation_id, reservation.state
244        )));
245    }
246    match chrono::DateTime::parse_from_rfc3339(&reservation.expires_at) {
247        Ok(expires_at) => {
248            if expires_at <= chrono::Utc::now() {
249                return Err(ComputerUseError::IdentityMismatch(format!(
250                    "target reservation {} expired at {}",
251                    reservation.reservation_id, reservation.expires_at
252                )));
253            }
254        }
255        Err(error) => {
256            return Err(ComputerUseError::IdentityMismatch(format!(
257                "target reservation {} has an unreadable expiry {:?}: {error}",
258                reservation.reservation_id, reservation.expires_at
259            )));
260        }
261    }
262
263    let target = envelope.target.as_ref().ok_or_else(|| {
264        ComputerUseError::IdentityMismatch(format!(
265            "target reservation {} was returned for action {} without target evidence",
266            reservation.reservation_id, envelope.action_id
267        ))
268    })?;
269    if reservation.scope.app_id != target.app_id {
270        return Err(mismatch(OBJECT, "scope.app_id", &target.app_id, &reservation.scope.app_id));
271    }
272    if reservation.scope.window_id != target.window_id {
273        return Err(mismatch(
274            OBJECT,
275            "scope.window_id",
276            &format!("{:?}", target.window_id),
277            &format!("{:?}", reservation.scope.window_id),
278        ));
279    }
280
281    Ok(())
282}
283
284/// Verifies a receipt describes the action that was actually submitted.
285///
286/// The digest is the strongest binding available: `ActionEnvelope::args_digest` is what
287/// approval was granted against, so a receipt carrying a different digest describes different
288/// work regardless of matching identifiers. An empty expected digest is treated as
289/// unavailable rather than as a match against an empty value.
290///
291/// # Errors
292///
293/// Returns [`ComputerUseError::IdentityMismatch`] when the session, action ID, or action
294/// digest disagrees with `envelope`.
295pub fn validate_receipt(
296    receipt: &ExecutionReceipt,
297    envelope: &ActionEnvelope,
298    expected_digest: &str,
299) -> Result<(), ComputerUseError> {
300    const OBJECT: &str = "execution receipt";
301
302    if receipt.session_id != envelope.session_id {
303        return Err(mismatch(OBJECT, "session_id", &envelope.session_id, &receipt.session_id));
304    }
305    if receipt.action_id != envelope.action_id {
306        return Err(mismatch(OBJECT, "action_id", &envelope.action_id, &receipt.action_id));
307    }
308    if !expected_digest.is_empty() && receipt.action_digest != expected_digest {
309        return Err(mismatch(OBJECT, "action_digest", expected_digest, &receipt.action_digest));
310    }
311
312    Ok(())
313}