use std::time::Duration;
use agent_client_protocol::{
Client, ConnectionTo,
schema::v1::{
PermissionOption, PermissionOptionKind, RequestPermissionOutcome, RequestPermissionRequest,
SessionId, ToolCallUpdate, ToolCallUpdateFields,
},
};
use basis::approval::{ApprovalAnswer, ApprovalDecision, ApprovalRequest, Approver};
const ALLOW_ONCE: &str = "allow-once";
const ALLOW_ALWAYS: &str = "allow-always";
const REJECT_ONCE: &str = "reject-once";
const REJECT_ALWAYS: &str = "reject-always";
const ANSWER_TIMEOUT: Duration = Duration::from_secs(30 * 60);
pub struct AcpApprover {
session_id: SessionId,
connection: ConnectionTo<Client>,
}
impl AcpApprover {
pub fn new(session_id: SessionId, connection: ConnectionTo<Client>) -> Self {
Self {
session_id,
connection,
}
}
}
#[async_trait::async_trait]
impl Approver for AcpApprover {
async fn approve(&mut self, request: &ApprovalRequest) -> ApprovalAnswer {
let outbound = RequestPermissionRequest::new(
self.session_id.clone(),
ToolCallUpdate::new(
request.tool_call_id.clone(),
ToolCallUpdateFields::new()
.title(request.description.clone())
.raw_input(request.input.clone()),
),
options(),
);
match round_trip(&self.connection, outbound).await {
Ok(outcome) => answer(&outcome, &request.tool_name),
Err(why) => refused(&request.tool_name, &why),
}
}
}
fn refused(tool_name: &str, why: &str) -> ApprovalAnswer {
ApprovalAnswer::new(ApprovalDecision::Deny)
.because(format!("{tool_name} needs approval and {why}"))
}
async fn round_trip(
connection: &ConnectionTo<Client>,
request: RequestPermissionRequest,
) -> Result<RequestPermissionOutcome, String> {
let response = tokio::time::timeout(
ANSWER_TIMEOUT,
connection.send_request(request).block_task(),
)
.await;
match response {
Ok(Ok(response)) => Ok(response.outcome),
Ok(Err(_)) => Err("the client could not be reached".to_string()),
Err(_) => Err(format!(
"the client did not answer within {} minutes",
ANSWER_TIMEOUT.as_secs() / 60
)),
}
}
fn options() -> Vec<PermissionOption> {
vec![
PermissionOption::new(ALLOW_ONCE, "Allow", PermissionOptionKind::AllowOnce),
PermissionOption::new(
ALLOW_ALWAYS,
"Allow for this session",
PermissionOptionKind::AllowAlways,
),
PermissionOption::new(REJECT_ONCE, "Deny", PermissionOptionKind::RejectOnce),
PermissionOption::new(
REJECT_ALWAYS,
"Deny for this session",
PermissionOptionKind::RejectAlways,
),
]
}
fn answer(outcome: &RequestPermissionOutcome, tool_name: &str) -> ApprovalAnswer {
let RequestPermissionOutcome::Selected(selected) = outcome else {
return refused(tool_name, "the request was cancelled");
};
match &*selected.option_id.0 {
ALLOW_ONCE => ApprovalDecision::Allow.into(),
ALLOW_ALWAYS => ApprovalDecision::AllowForSession.into(),
REJECT_ONCE => ApprovalAnswer::new(ApprovalDecision::Deny)
.because(format!("{tool_name} was refused by the client")),
REJECT_ALWAYS => ApprovalAnswer::new(ApprovalDecision::DenyForSession).because(format!(
"{tool_name} was refused by the client, for the rest of this session"
)),
_ => refused(tool_name, "the client's answer could not be read"),
}
}
#[cfg(test)]
fn is_known_option(id: &str) -> bool {
matches!(id, ALLOW_ONCE | ALLOW_ALWAYS | REJECT_ONCE | REJECT_ALWAYS)
}
#[cfg(test)]
mod tests {
use super::*;
use agent_client_protocol::schema::v1::{PermissionOptionId, SelectedPermissionOutcome};
fn selected(id: &str) -> RequestPermissionOutcome {
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(PermissionOptionId::new(
id.to_string(),
)))
}
#[test]
fn every_offered_option_maps_to_a_decision() {
assert_eq!(
answer(&selected(ALLOW_ONCE), "shell").decision,
ApprovalDecision::Allow
);
assert_eq!(
answer(&selected(ALLOW_ALWAYS), "shell").decision,
ApprovalDecision::AllowForSession
);
assert_eq!(
answer(&selected(REJECT_ONCE), "shell").decision,
ApprovalDecision::Deny
);
assert_eq!(
answer(&selected(REJECT_ALWAYS), "shell").decision,
ApprovalDecision::DenyForSession
);
}
#[test]
fn allowing_needs_no_reason_and_refusing_gives_one() {
assert_eq!(answer(&selected(ALLOW_ONCE), "shell").reason, None);
for id in [REJECT_ONCE, REJECT_ALWAYS] {
let reason = answer(&selected(id), "shell")
.reason
.unwrap_or_else(|| panic!("{id} must explain itself"));
assert!(reason.starts_with("shell "), "{reason}");
}
}
#[test]
fn the_offered_options_are_exactly_the_ones_understood() {
for option in options() {
assert!(
is_known_option(&option.option_id.0),
"offered {} but cannot map it",
option.option_id.0
);
}
assert_eq!(options().len(), 4);
}
#[test]
fn a_cancelled_request_denies() {
let answer = answer(&RequestPermissionOutcome::Cancelled, "shell");
assert_eq!(
answer.decision,
ApprovalDecision::Deny,
"a cancelled turn has nothing left to authorize"
);
assert_eq!(
answer.reason.as_deref(),
Some("shell needs approval and the request was cancelled")
);
}
#[test]
fn an_unrecognized_answer_denies() {
let answer = answer(&selected("something-else"), "shell");
assert_eq!(
answer.decision,
ApprovalDecision::Deny,
"an answer basis cannot read must not be treated as consent"
);
assert_eq!(
answer.reason.as_deref(),
Some("shell needs approval and the client's answer could not be read")
);
}
#[test]
fn a_client_that_never_answers_says_so_in_minutes() {
assert_eq!(
refused("shell", "the client did not answer within 30 minutes")
.reason
.as_deref(),
Some("shell needs approval and the client did not answer within 30 minutes")
);
assert_eq!(ANSWER_TIMEOUT.as_secs() / 60, 30);
}
}