1use std::time::Duration;
20
21use agent_client_protocol::{
22 Client, ConnectionTo,
23 schema::v1::{
24 PermissionOption, PermissionOptionKind, RequestPermissionOutcome, RequestPermissionRequest,
25 SessionId, ToolCallUpdate, ToolCallUpdateFields,
26 },
27};
28
29use basis::approval::{ApprovalAnswer, ApprovalDecision, ApprovalRequest, Approver};
30
31const ALLOW_ONCE: &str = "allow-once";
35const ALLOW_ALWAYS: &str = "allow-always";
36const REJECT_ONCE: &str = "reject-once";
37const REJECT_ALWAYS: &str = "reject-always";
38
39const ANSWER_TIMEOUT: Duration = Duration::from_secs(30 * 60);
45
46pub struct AcpApprover {
48 session_id: SessionId,
49 connection: ConnectionTo<Client>,
50}
51
52impl AcpApprover {
53 pub fn new(session_id: SessionId, connection: ConnectionTo<Client>) -> Self {
54 Self {
55 session_id,
56 connection,
57 }
58 }
59}
60
61#[async_trait::async_trait]
62impl Approver for AcpApprover {
63 async fn approve(&mut self, request: &ApprovalRequest) -> ApprovalAnswer {
64 let outbound = RequestPermissionRequest::new(
65 self.session_id.clone(),
66 ToolCallUpdate::new(
67 request.tool_call_id.clone(),
68 ToolCallUpdateFields::new()
69 .title(request.description.clone())
70 .raw_input(request.input.clone()),
71 ),
72 options(),
73 );
74
75 match round_trip(&self.connection, outbound).await {
76 Ok(outcome) => answer(&outcome, &request.tool_name),
77 Err(why) => refused(&request.tool_name, &why),
81 }
82 }
83}
84
85fn refused(tool_name: &str, why: &str) -> ApprovalAnswer {
87 ApprovalAnswer::new(ApprovalDecision::Deny)
88 .because(format!("{tool_name} needs approval and {why}"))
89}
90
91async fn round_trip(
93 connection: &ConnectionTo<Client>,
94 request: RequestPermissionRequest,
95) -> Result<RequestPermissionOutcome, String> {
96 let response = tokio::time::timeout(
97 ANSWER_TIMEOUT,
98 connection.send_request(request).block_task(),
99 )
100 .await;
101
102 match response {
103 Ok(Ok(response)) => Ok(response.outcome),
104 Ok(Err(_)) => Err("the client could not be reached".to_string()),
105 Err(_) => Err(format!(
106 "the client did not answer within {} minutes",
107 ANSWER_TIMEOUT.as_secs() / 60
108 )),
109 }
110}
111
112fn options() -> Vec<PermissionOption> {
117 vec![
118 PermissionOption::new(ALLOW_ONCE, "Allow", PermissionOptionKind::AllowOnce),
119 PermissionOption::new(
120 ALLOW_ALWAYS,
121 "Allow for this session",
122 PermissionOptionKind::AllowAlways,
123 ),
124 PermissionOption::new(REJECT_ONCE, "Deny", PermissionOptionKind::RejectOnce),
125 PermissionOption::new(
126 REJECT_ALWAYS,
127 "Deny for this session",
128 PermissionOptionKind::RejectAlways,
129 ),
130 ]
131}
132
133fn answer(outcome: &RequestPermissionOutcome, tool_name: &str) -> ApprovalAnswer {
139 let RequestPermissionOutcome::Selected(selected) = outcome else {
140 return refused(tool_name, "the request was cancelled");
142 };
143
144 match &*selected.option_id.0 {
145 ALLOW_ONCE => ApprovalDecision::Allow.into(),
146 ALLOW_ALWAYS => ApprovalDecision::AllowForSession.into(),
147 REJECT_ONCE => ApprovalAnswer::new(ApprovalDecision::Deny)
148 .because(format!("{tool_name} was refused by the client")),
149 REJECT_ALWAYS => ApprovalAnswer::new(ApprovalDecision::DenyForSession).because(format!(
150 "{tool_name} was refused by the client, for the rest of this session"
151 )),
152 _ => refused(tool_name, "the client's answer could not be read"),
153 }
154}
155
156#[cfg(test)]
159fn is_known_option(id: &str) -> bool {
160 matches!(id, ALLOW_ONCE | ALLOW_ALWAYS | REJECT_ONCE | REJECT_ALWAYS)
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use agent_client_protocol::schema::v1::{PermissionOptionId, SelectedPermissionOutcome};
167
168 fn selected(id: &str) -> RequestPermissionOutcome {
169 RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(PermissionOptionId::new(
170 id.to_string(),
171 )))
172 }
173
174 #[test]
175 fn every_offered_option_maps_to_a_decision() {
176 assert_eq!(
177 answer(&selected(ALLOW_ONCE), "shell").decision,
178 ApprovalDecision::Allow
179 );
180 assert_eq!(
181 answer(&selected(ALLOW_ALWAYS), "shell").decision,
182 ApprovalDecision::AllowForSession
183 );
184 assert_eq!(
185 answer(&selected(REJECT_ONCE), "shell").decision,
186 ApprovalDecision::Deny
187 );
188 assert_eq!(
189 answer(&selected(REJECT_ALWAYS), "shell").decision,
190 ApprovalDecision::DenyForSession
191 );
192 }
193
194 #[test]
195 fn allowing_needs_no_reason_and_refusing_gives_one() {
196 assert_eq!(answer(&selected(ALLOW_ONCE), "shell").reason, None);
199
200 for id in [REJECT_ONCE, REJECT_ALWAYS] {
201 let reason = answer(&selected(id), "shell")
202 .reason
203 .unwrap_or_else(|| panic!("{id} must explain itself"));
204 assert!(reason.starts_with("shell "), "{reason}");
205 }
206 }
207
208 #[test]
209 fn the_offered_options_are_exactly_the_ones_understood() {
210 for option in options() {
213 assert!(
214 is_known_option(&option.option_id.0),
215 "offered {} but cannot map it",
216 option.option_id.0
217 );
218 }
219 assert_eq!(options().len(), 4);
220 }
221
222 #[test]
223 fn a_cancelled_request_denies() {
224 let answer = answer(&RequestPermissionOutcome::Cancelled, "shell");
225
226 assert_eq!(
227 answer.decision,
228 ApprovalDecision::Deny,
229 "a cancelled turn has nothing left to authorize"
230 );
231 assert_eq!(
232 answer.reason.as_deref(),
233 Some("shell needs approval and the request was cancelled")
234 );
235 }
236
237 #[test]
238 fn an_unrecognized_answer_denies() {
239 let answer = answer(&selected("something-else"), "shell");
240
241 assert_eq!(
242 answer.decision,
243 ApprovalDecision::Deny,
244 "an answer basis cannot read must not be treated as consent"
245 );
246 assert_eq!(
247 answer.reason.as_deref(),
248 Some("shell needs approval and the client's answer could not be read")
249 );
250 }
251
252 #[test]
253 fn a_client_that_never_answers_says_so_in_minutes() {
254 assert_eq!(
257 refused("shell", "the client did not answer within 30 minutes")
258 .reason
259 .as_deref(),
260 Some("shell needs approval and the client did not answer within 30 minutes")
261 );
262 assert_eq!(ANSWER_TIMEOUT.as_secs() / 60, 30);
263 }
264}