agent_effects/
approval.rs1use std::fmt;
23use std::future::Future;
24use std::io::{BufRead, Write};
25use std::pin::Pin;
26use std::sync::{Arc, Mutex, PoisonError};
27
28use serde_json::Value;
29
30use crate::id::{EffectId, EffectKey};
31use crate::kind::EffectKind;
32use crate::policy::RiskLevel;
33
34#[derive(Clone, Debug, PartialEq)]
36pub struct ApprovalRequest {
37 pub effect_id: EffectId,
40 pub key: EffectKey,
42 pub kind: EffectKind,
44 pub risk: RiskLevel,
46 pub input: Option<Value>,
48 pub requested_by: Option<String>,
50}
51
52impl fmt::Display for ApprovalRequest {
53 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54 writeln!(
55 f,
56 " effect: {} ({:?}, {} risk)",
57 self.key, self.kind, self.risk
58 )?;
59 writeln!(f, " id: {}", self.effect_id)?;
60 if let Some(by) = &self.requested_by {
61 writeln!(f, " by: {by}")?;
62 }
63 if let Some(input) = &self.input {
64 writeln!(f, " input: {input}")?;
65 }
66 Ok(())
67 }
68}
69
70#[derive(Clone, Debug, PartialEq, Eq)]
72pub enum ApprovalDecision {
73 Approved {
75 by: String,
77 },
78 Denied {
80 by: String,
82 reason: String,
84 },
85 Deferred,
87}
88
89pub trait ApprovalProvider: Send + Sync + 'static {
91 fn request(&self, request: ApprovalRequest) -> impl Future<Output = ApprovalDecision> + Send;
94}
95
96type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
97
98pub(crate) trait ErasedApproval: Send + Sync + 'static {
100 fn request_boxed(&self, request: ApprovalRequest) -> BoxFuture<'_, ApprovalDecision>;
101}
102
103impl<P: ApprovalProvider> ErasedApproval for P {
104 fn request_boxed(&self, request: ApprovalRequest) -> BoxFuture<'_, ApprovalDecision> {
105 Box::pin(self.request(request))
106 }
107}
108
109pub struct CliApproval {
114 io: Arc<Mutex<CliIo>>,
115 approver: String,
116}
117
118struct CliIo {
119 input: Box<dyn BufRead + Send>,
120 output: Box<dyn Write + Send>,
121}
122
123impl CliApproval {
124 pub fn new() -> Self {
127 let user = std::env::var("USER").unwrap_or_else(|_| "unknown".into());
128 Self::with_io(std::io::BufReader::new(std::io::stdin()), std::io::stderr())
129 .approver(format!("cli:{user}"))
130 }
131
132 pub fn with_io(
134 input: impl BufRead + Send + 'static,
135 output: impl Write + Send + 'static,
136 ) -> Self {
137 Self {
138 io: Arc::new(Mutex::new(CliIo {
139 input: Box::new(input),
140 output: Box::new(output),
141 })),
142 approver: "cli".into(),
143 }
144 }
145
146 #[must_use]
148 pub fn approver(mut self, name: impl Into<String>) -> Self {
149 self.approver = name.into();
150 self
151 }
152}
153
154impl Default for CliApproval {
155 fn default() -> Self {
156 Self::new()
157 }
158}
159
160impl ApprovalProvider for CliApproval {
161 async fn request(&self, request: ApprovalRequest) -> ApprovalDecision {
162 let (io, by) = (Arc::clone(&self.io), self.approver.clone());
163 tokio::task::spawn_blocking(move || {
165 let mut io = io.lock().unwrap_or_else(PoisonError::into_inner);
166 let asked = write!(
167 io.output,
168 "agent-effects: approval needed\n{request}Approve? [y/N]: "
169 )
170 .and_then(|()| io.output.flush());
171 if asked.is_err() {
172 return ApprovalDecision::Deferred;
173 }
174 let mut answer = String::new();
175 match io.input.read_line(&mut answer) {
176 Ok(0) | Err(_) => ApprovalDecision::Deferred,
177 Ok(_) => match answer.trim().to_ascii_lowercase().as_str() {
178 "y" | "yes" => ApprovalDecision::Approved { by },
179 _ => ApprovalDecision::Denied {
180 by,
181 reason: "denied at the command line".into(),
182 },
183 },
184 }
185 })
186 .await
187 .unwrap_or(ApprovalDecision::Deferred)
188 }
189}