Skip to main content

adk_computer_use/contracts/
approval.rs

1//! Disclosure-safe approval grants returned by the runtime approval boundary.
2
3use super::action::{ActionClass, ExecutionMode};
4use serde::{Deserialize, Serialize};
5use std::collections::HashSet;
6
7/// Scope of an [`ApprovalGrant`].
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum ApprovalGrantScope {
11    /// Authority bound to exactly one action digest and a single use.
12    ExactAction,
13    /// Authority bound to a bounded reversible semantic edit operation.
14    SessionOperation,
15}
16
17/// Read-only, disclosure-safe authority returned by the runtime approval boundary.
18///
19/// Values are validated on deserialization: digests must be lowercase SHA-256
20/// hex, use accounting is bounded, and each scope enforces its own binding
21/// rules (`ExactAction` binds one digest/use; `SessionOperation` must be a
22/// bounded reversible `set_value`/`fill_form` edit). The bearer token never
23/// enters ADK or model state — only this read-only descriptor does.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(try_from = "RawApprovalGrant", into = "RawApprovalGrant")]
26pub struct ApprovalGrant {
27    grant_id: String,
28    scope: ApprovalGrantScope,
29    principal_id: String,
30    session_id: String,
31    action_digest: String,
32    scope_digest: String,
33    policy_digest: String,
34    tool: String,
35    operation: String,
36    action_class: ActionClass,
37    mode: ExecutionMode,
38    issued_at: String,
39    expires_at: String,
40    remaining_uses: u32,
41    consumed_by_action_ids: Vec<String>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase", deny_unknown_fields)]
46struct RawApprovalGrant {
47    grant_id: String,
48    scope: ApprovalGrantScope,
49    principal_id: String,
50    session_id: String,
51    action_digest: String,
52    scope_digest: String,
53    policy_digest: String,
54    tool: String,
55    operation: String,
56    action_class: ActionClass,
57    mode: ExecutionMode,
58    issued_at: String,
59    expires_at: String,
60    remaining_uses: u32,
61    consumed_by_action_ids: Vec<String>,
62}
63
64fn is_sha256_hex(value: &str) -> bool {
65    value.len() == 64
66        && value.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
67}
68
69impl TryFrom<RawApprovalGrant> for ApprovalGrant {
70    type Error = String;
71
72    fn try_from(raw: RawApprovalGrant) -> Result<Self, Self::Error> {
73        if raw.grant_id.is_empty()
74            || raw.principal_id.is_empty()
75            || raw.session_id.is_empty()
76            || raw.policy_digest.is_empty()
77            || raw.issued_at.is_empty()
78            || raw.expires_at.is_empty()
79        {
80            return Err("approval grant identity, policy, and timestamps must not be empty".into());
81        }
82        if !is_sha256_hex(&raw.action_digest) || !is_sha256_hex(&raw.scope_digest) {
83            return Err("approval grant digests must be lowercase SHA-256 hex".into());
84        }
85        if raw.remaining_uses > 100 || raw.consumed_by_action_ids.len() > 100 {
86            return Err("approval grant use accounting exceeds the wire limit".into());
87        }
88        let unique: HashSet<&str> = raw.consumed_by_action_ids.iter().map(String::as_str).collect();
89        if unique.len() != raw.consumed_by_action_ids.len()
90            || raw.consumed_by_action_ids.iter().any(String::is_empty)
91        {
92            return Err("approval grant consumed action IDs must be nonempty and unique".into());
93        }
94        match raw.scope {
95            ApprovalGrantScope::ExactAction => {
96                if raw.scope_digest != raw.action_digest
97                    || raw.remaining_uses > 1
98                    || raw.consumed_by_action_ids.len() > 1
99                {
100                    return Err("exact-action approval must bind one action digest and use".into());
101                }
102            }
103            ApprovalGrantScope::SessionOperation => {
104                if !matches!(raw.tool.as_str(), "set_value" | "fill_form")
105                    || raw.operation.is_empty()
106                    || raw.action_class != ActionClass::EditReversible
107                    || raw.remaining_uses + raw.consumed_by_action_ids.len() as u32 > 20
108                {
109                    return Err(
110                        "session-operation approval must be a bounded reversible semantic edit"
111                            .into(),
112                    );
113                }
114            }
115        }
116        Ok(Self {
117            grant_id: raw.grant_id,
118            scope: raw.scope,
119            principal_id: raw.principal_id,
120            session_id: raw.session_id,
121            action_digest: raw.action_digest,
122            scope_digest: raw.scope_digest,
123            policy_digest: raw.policy_digest,
124            tool: raw.tool,
125            operation: raw.operation,
126            action_class: raw.action_class,
127            mode: raw.mode,
128            issued_at: raw.issued_at,
129            expires_at: raw.expires_at,
130            remaining_uses: raw.remaining_uses,
131            consumed_by_action_ids: raw.consumed_by_action_ids,
132        })
133    }
134}
135
136impl From<ApprovalGrant> for RawApprovalGrant {
137    fn from(value: ApprovalGrant) -> Self {
138        Self {
139            grant_id: value.grant_id,
140            scope: value.scope,
141            principal_id: value.principal_id,
142            session_id: value.session_id,
143            action_digest: value.action_digest,
144            scope_digest: value.scope_digest,
145            policy_digest: value.policy_digest,
146            tool: value.tool,
147            operation: value.operation,
148            action_class: value.action_class,
149            mode: value.mode,
150            issued_at: value.issued_at,
151            expires_at: value.expires_at,
152            remaining_uses: value.remaining_uses,
153            consumed_by_action_ids: value.consumed_by_action_ids,
154        }
155    }
156}
157
158impl ApprovalGrant {
159    /// The unique grant identifier.
160    pub fn grant_id(&self) -> &str {
161        &self.grant_id
162    }
163
164    /// The scope of authority this grant confers.
165    pub fn scope(&self) -> ApprovalGrantScope {
166        self.scope
167    }
168
169    /// The digest the grant's scope is bound to.
170    pub fn scope_digest(&self) -> &str {
171        &self.scope_digest
172    }
173
174    /// Remaining uses before the grant is exhausted.
175    pub fn remaining_uses(&self) -> u32 {
176        self.remaining_uses
177    }
178}