Skip to main content

agent_effects/
approval.rs

1//! Human approval before an effect runs.
2//!
3//! An effect that requires approval
4//! ([`EffectBuilder::require_approval`](crate::EffectBuilder::require_approval),
5//! [`EffectHandler::requires_approval`](crate::EffectHandler::requires_approval))
6//! goes `Pending → AwaitingApproval` before its first attempt. The record is
7//! durable, so a pending approval survives restarts. Decisions come from two
8//! places:
9//!
10//! - the runtime's [`ApprovalProvider`], asked when the effect needs a
11//!   decision and again on every later call while none has been made. It
12//!   may answer at once (a CLI prompt), or return
13//!   [`ApprovalDecision::Deferred`] and let the decision arrive later
14//!   (Slack, a dashboard);
15//! - an operator, through [`Runtime::approve`](crate::Runtime::approve) or
16//!   [`Runtime::deny`](crate::Runtime::deny).
17//!
18//! Approval is asked once: an approved effect is never asked again, even if
19//! it retries. The precondition is checked again after approval, since the
20//! decision may have taken a while.
21
22use 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/// What an approver is asked to decide.
35#[derive(Clone, Debug, PartialEq)]
36pub struct ApprovalRequest {
37    /// The effect's record id. Stable across repeated requests for the same
38    /// effect, so a provider can deduplicate them.
39    pub effect_id: EffectId,
40    /// What the effect is.
41    pub key: EffectKey,
42    /// How reversible it is.
43    pub kind: EffectKind,
44    /// How much damage it could do.
45    pub risk: RiskLevel,
46    /// The stored input.
47    pub input: Option<Value>,
48    /// Who asked for the effect, e.g. `agent:refund-agent`.
49    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/// An approver's answer.
71#[derive(Clone, Debug, PartialEq, Eq)]
72pub enum ApprovalDecision {
73    /// Go ahead.
74    Approved {
75        /// Who approved, recorded as the audit event's actor.
76        by: String,
77    },
78    /// Never run it. The effect ends `Rejected` with `reason`.
79    Denied {
80        /// Who denied.
81        by: String,
82        /// Why; recorded as the effect's error.
83        reason: String,
84    },
85    /// No decision yet. The effect stays `AwaitingApproval`.
86    Deferred,
87}
88
89/// Decides whether effects that require approval may run.
90pub trait ApprovalProvider: Send + Sync + 'static {
91    /// Asks for a decision. May be called again for the same effect while
92    /// no decision has been made.
93    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
98/// [`ApprovalProvider`], erased so the runtime can hold any provider.
99pub(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
109/// Asks at the terminal: prints the request and reads `y` or `n`.
110///
111/// Anything but `y`/`yes` denies. End of input (no human attached) defers,
112/// leaving the effect for an operator.
113pub 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    /// Reads standard input and writes to standard error. The approver is
125    /// recorded as `cli:$USER`.
126    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    /// Reads answers from `input` and writes prompts to `output`.
133    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    /// The name recorded as approver or denier.
147    #[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        // Reading a terminal blocks: keep it off the async workers.
164        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}