Skip to main content

basis/
approval.rs

1//! Asking before the agent does something consequential.
2//!
3//! Two pieces, and only two. [`ApprovalGate`] is the tool authorizer basis
4//! installs on every runtime; it answers one question — *is this call worth
5//! asking about* — and puts every call where the answer is yes to whoever is
6//! answering. [`Approver`] is whoever that is, and it is the only thing that
7//! decides.
8//!
9//! There was a third piece until ADR-0010: an `ApprovalPolicy` enum the core
10//! interpreted, whose three values were three trait impls in disguise. Two of
11//! them ship here — [`AllowAll`] and [`DenyAll`] — and the third, asking a
12//! person, lives where the terminal is: `basis-acp` supplies an approver that
13//! asks the client, and the binary one that asks at a TTY (ADR-0011). What the
14//! enum could never express, the trait can: allow edits but deny the network,
15//! ask over Slack with a timeout, escalate after the third refusal.
16//!
17//! The first of those is the one this module has to make *writable* rather than
18//! merely describable, and it is written on [`Approver`]. It reads
19//! [`ApprovalRequest::side_effect_level`] and names no tool, which is the whole
20//! point: a policy spelled as a list of tool names is a policy that silently
21//! stops covering the next MCP server a workspace connects.
22//!
23//! Nothing installs an approver by default, and that is deliberate: with no
24//! approver the run gets [`AllowAll`], which is what a headless run needs.
25//! Anything stricter is one argument to
26//! [`run_with_approver`](crate::run::run_with_approver).
27
28use std::time::Duration;
29
30use async_trait::async_trait;
31use mentra::{
32    error::RuntimeError,
33    tool::{ToolAuthorizationDecision, ToolAuthorizationRequest, ToolAuthorizer},
34};
35use serde_json::Value;
36
37/// How far outside this process a call reaches: nothing, this machine's state,
38/// another process, or the world.
39///
40/// mentra's, deliberately, and re-exported here under the rule written on
41/// [`CancellationToken`](crate::CancellationToken) — every mentra type basis's
42/// surface makes a caller *name*, basis re-exports. Both
43/// [`is_consequential`] and [`ApprovalRequest::side_effect_level`] ask an
44/// approver to name it, and without this line writing the policy those exist
45/// for would mean adding mentra to the host's own manifest, pinned to whatever
46/// version basis happens to resolve.
47pub use mentra::tool::ToolSideEffectLevel;
48
49/// What the agent wants to do, as put to an [`Approver`].
50///
51/// Deliberately not `#[non_exhaustive]`, though hosts read it far more often
52/// than they build one. The struct has no constructor and no builder, so
53/// sealing it would make an `ApprovalRequest` *unconstructable* outside this
54/// crate — and every host testing its own approver builds one, as `basis-acp`
55/// and `basis-cli` both do. Sealing would trade a compile error that names the
56/// new field, on the day a field is added, for a permanent one with no way past
57/// it.
58#[derive(Debug, Clone, PartialEq)]
59pub struct ApprovalRequest {
60    pub request_id: String,
61    pub tool_call_id: String,
62    pub tool_name: String,
63    /// Why approval is being asked for.
64    pub description: String,
65    /// The tool's input, parsed when it is JSON.
66    pub input: Value,
67    /// How far outside this process the call reaches, when basis knows.
68    ///
69    /// This is what lets a policy be written about *what a call does* rather
70    /// than about which tools happen to be installed —
71    /// [`LocalState`](ToolSideEffectLevel::LocalState) for an edit to this
72    /// checkout, [`External`](ToolSideEffectLevel::External) for an MCP server
73    /// or a declared tool that leaves the machine. See [`Approver`] for the
74    /// worked example.
75    ///
76    /// **`None` means unknown, never harmless.** Read-only calls do not reach
77    /// an approver at all ([`is_consequential`]), so nothing that arrives here
78    /// is a read; a `None` is only ever basis failing to recover a fact it
79    /// could not carry. The fail-closed reading is to treat it as
80    /// [`External`](ToolSideEffectLevel::External) — judge it by the most it
81    /// could be doing, the same rule the rest of this module runs on.
82    ///
83    /// It is an `Option` because mentra types the classification as one:
84    /// since [mentra#21](https://github.com/oops-rs/mentra/issues/21) the
85    /// `PermissionRequested` event carries the call's classification, and
86    /// mentra documents it as always present on a live request — but that is
87    /// mentra's invariant to keep, not basis's to unwrap, so basis passes on
88    /// what the event says. An approver reads `None` by the rule above:
89    /// unknown, never harmless.
90    pub side_effect_level: Option<ToolSideEffectLevel>,
91}
92
93/// What an [`Approver`] decided.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub enum ApprovalDecision {
96    Allow,
97    /// The default: when in doubt, do not.
98    #[default]
99    Deny,
100    /// Allow, and stop asking about this tool for the rest of the session.
101    AllowForSession,
102    /// Deny, and stop asking about this tool for the rest of the session.
103    DenyForSession,
104}
105
106/// How an [`Approver`] answered: the decision, and — when it refused — why.
107///
108/// The reason is not decoration. A denial reaches the model as that tool
109/// call's result, so the wording is the only thing telling it what to do
110/// next: a model told merely that something was denied tries the write
111/// again, and one told this run does not allow writes stops and reports.
112/// An answer that leaves it unset still denies; the model just reads
113/// mentra's standing "denied by session approver" instead.
114///
115/// Allowing needs no reason, because an allowed call explains itself by
116/// happening.
117#[derive(Debug, Clone, PartialEq, Eq, Default)]
118pub struct ApprovalAnswer {
119    pub decision: ApprovalDecision,
120    pub reason: Option<String>,
121}
122
123impl ApprovalAnswer {
124    /// An answer that says only what it decided.
125    pub fn new(decision: ApprovalDecision) -> Self {
126        Self {
127            decision,
128            reason: None,
129        }
130    }
131
132    /// The same answer, carrying the words the model will read.
133    pub fn because(self, reason: impl Into<String>) -> Self {
134        Self {
135            reason: Some(reason.into()),
136            ..self
137        }
138    }
139}
140
141impl From<ApprovalDecision> for ApprovalAnswer {
142    fn from(decision: ApprovalDecision) -> Self {
143        Self::new(decision)
144    }
145}
146
147/// Answers approval requests. The seam a host plugs its own judgment into.
148///
149/// Called from the event-forwarding task while the turn is blocked inside
150/// mentra waiting, so an implementation must answer rather than defer to
151/// something that only happens after the run.
152///
153/// Async because answering genuinely takes time and the caller is an async
154/// task: an ACP approver awaits a round trip to the client, and a terminal one
155/// waits on a person. A synchronous signature would force both to block a
156/// runtime worker thread — which tokio rejects outright for the ACP case. The
157/// attribute to spell an impl with is re-exported at the crate root —
158/// [`async_trait`](crate::async_trait) — so it costs no manifest line of the
159/// host's own.
160///
161/// # Fail closed
162///
163/// **An approver that cannot answer denies.** No terminal to ask at, an answer
164/// that never came, a channel whose other end is gone: none of those is
165/// consent, and the only calls that reach an approver are the ones that change
166/// something outside this process.
167///
168/// The worked example is the binary's `TerminalApprover`. Asked when stdin is
169/// not a terminal — an unattended `basis spawn --approve prompt`, a cron job — it
170/// denies without printing a question nobody would read, so the run fails
171/// visibly instead of quietly granting whatever came up. `basis-acp`'s client
172/// approver applies the same rule to a failed round trip, a cancelled request,
173/// an answer it cannot parse, and its own thirty-minute timeout.
174///
175/// Each of those denials should say which one it was, on the
176/// [`reason`](ApprovalAnswer::reason) of its answer. Failing closed silently
177/// leaves the model to guess, and it guesses that retrying will work.
178///
179/// [`ApprovalDecision`]'s own default is [`Deny`](ApprovalDecision::Deny) for
180/// the same reason, and so is mentra's when an authorizer times out: silence is
181/// never a yes.
182///
183/// # Allow edits, deny the network
184///
185/// The policy this module's own documentation has always named as the reason
186/// the seam is a trait, written out. Nothing in it names a tool — every call is
187/// judged by how far it reaches — so a workspace that connects a new MCP server
188/// tomorrow, or ships a `.basis/tools.json` declaring a program, is covered by
189/// the rule that was already there.
190///
191/// ```
192/// use basis::{
193///     ApprovalAnswer, ApprovalDecision, ApprovalRequest, Approver, ToolSideEffectLevel,
194///     async_trait,
195/// };
196///
197/// struct EditsButNotTheNetwork;
198///
199/// #[async_trait]
200/// impl Approver for EditsButNotTheNetwork {
201///     async fn approve(&mut self, request: &ApprovalRequest) -> ApprovalAnswer {
202///         match request.side_effect_level {
203///             // Changes this machine's state and nothing past it: the file
204///             // tools, and a delegation to a subagent.
205///             Some(ToolSideEffectLevel::LocalState) => ApprovalDecision::Allow.into(),
206///
207///             // Everything else. `Process` is a command, which can reach the
208///             // network by running `curl`; `External` says so outright; and
209///             // `None` is a level basis could not recover, which is judged by
210///             // the most it could be rather than the least. `ToolSideEffectLevel::None`
211///             // never arrives — a read is not put to an approver at all.
212///             _ => ApprovalAnswer::new(ApprovalDecision::Deny)
213///                 .because("this run may change this checkout and nothing beyond it"),
214///         }
215///     }
216/// }
217/// ```
218#[async_trait]
219pub trait Approver: Send + 'static {
220    async fn approve(&mut self, request: &ApprovalRequest) -> ApprovalAnswer;
221}
222
223/// Forwards to the approver inside.
224///
225/// Lets a caller hold an approver it chose at runtime — one of several, or one
226/// a feature flag picked — and still pass it to anything taking
227/// `impl Approver`. The binary is exactly that caller: `--approve` names one of
228/// three, and without this each arm would have to duplicate the whole run.
229#[async_trait]
230impl<A: Approver + ?Sized> Approver for Box<A> {
231    async fn approve(&mut self, request: &ApprovalRequest) -> ApprovalAnswer {
232        (**self).approve(request).await
233    }
234}
235
236/// Approves everything. What a confined or headless run wants, and what a run
237/// given no approver of its own gets.
238#[derive(Debug, Default, Clone, Copy)]
239pub struct AllowAll;
240
241#[async_trait]
242impl Approver for AllowAll {
243    async fn approve(&mut self, _request: &ApprovalRequest) -> ApprovalAnswer {
244        ApprovalDecision::Allow.into()
245    }
246}
247
248/// Refuses everything, so the agent can inspect a workspace and report on it
249/// and cannot touch it. Each refusal reaches the model as a tool error, which
250/// is how it learns to stop trying.
251#[derive(Debug, Default, Clone, Copy)]
252pub struct DenyAll;
253
254#[async_trait]
255impl Approver for DenyAll {
256    async fn approve(&mut self, request: &ApprovalRequest) -> ApprovalAnswer {
257        ApprovalAnswer::new(ApprovalDecision::Deny).because(format!(
258            "{} changes state outside this process, which this run does not allow",
259            request.tool_name
260        ))
261    }
262}
263
264/// Whether a call changes anything outside this process.
265///
266/// Read-only calls are never worth asking about — prompting for them trains
267/// people to approve without reading, which is worse than not asking.
268pub fn is_consequential(level: ToolSideEffectLevel) -> bool {
269    !matches!(level, ToolSideEffectLevel::None)
270}
271
272/// Puts every consequential call to the [`Approver`], and lets the rest
273/// through.
274///
275/// This is the runtime half of approval, installed as mentra's
276/// `ToolAuthorizer`. It carries no policy: since ADR-0010 there is nothing left
277/// for one to say, because the approver decides. What it still owns is the
278/// filter — [`is_consequential`] — and the choice to *surface* rather than
279/// answer, which is what turns a call into a `PermissionRequested` event and
280/// blocks the turn until someone resolves it.
281///
282/// Installed even by a run that approves everything, and that is the point. An
283/// authorizer is fixed when the runtime is built and mentra never hands it
284/// back; without one it allows every call unconditionally and no permission
285/// request can ever be raised. Surfacing unconditionally is what lets the
286/// answer be chosen per turn — or changed mid-session, which is how an ACP
287/// client's mode picker works at all.
288#[derive(Debug, Default, Clone)]
289pub struct ApprovalGate {
290    timeout: Option<Duration>,
291}
292
293impl ApprovalGate {
294    pub fn new() -> Self {
295        Self {
296            // No timeout by default: a person reading a diff should not lose
297            // the turn to a stopwatch. A host that needs one sets it.
298            timeout: None,
299        }
300    }
301
302    /// Gives up on an unanswered request after `timeout`, denying the call.
303    ///
304    /// mentra applies this to the whole wait, so it bounds an approver that
305    /// never answers as well as one that answers slowly — the fail-closed rule
306    /// of [`Approver`], enforced from outside for approvers that forget it.
307    pub fn with_timeout(mut self, timeout: Duration) -> Self {
308        self.timeout = Some(timeout);
309        self
310    }
311}
312
313#[async_trait]
314impl ToolAuthorizer for ApprovalGate {
315    async fn authorize(
316        &self,
317        request: &ToolAuthorizationRequest,
318    ) -> Result<ToolAuthorizationDecision, RuntimeError> {
319        if !is_consequential(request.preview.side_effect_level) {
320            return Ok(ToolAuthorizationDecision::allow());
321        }
322
323        // The reason becomes the description the approver shows, so it says
324        // what is being asked rather than that something is.
325        Ok(ToolAuthorizationDecision::prompt(format!(
326            "{} wants to run and can change state outside this process",
327            request.tool_name
328        )))
329    }
330
331    fn timeout(&self) -> Option<Duration> {
332        self.timeout
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use mentra::tool::{
340        ToolApprovalCategory, ToolAuthorizationOutcome, ToolAuthorizationPreview, ToolCapability,
341        ToolDurability, ToolExecutionCategory,
342    };
343    use serde_json::json;
344    use std::path::PathBuf;
345
346    fn request(name: &str, level: ToolSideEffectLevel) -> ToolAuthorizationRequest {
347        ToolAuthorizationRequest {
348            agent_id: "a1".to_string(),
349            agent_name: "test".to_string(),
350            model: "m".to_string(),
351            history_len: 1,
352            tool_call_id: "tc-1".to_string(),
353            tool_name: name.to_string(),
354            preview: ToolAuthorizationPreview {
355                working_directory: PathBuf::from("/repo"),
356                capabilities: vec![ToolCapability::FilesystemWrite],
357                side_effect_level: level,
358                durability: ToolDurability::Ephemeral,
359                execution_category: ToolExecutionCategory::default(),
360                approval_category: ToolApprovalCategory::default(),
361                raw_input: json!({}),
362                structured_input: json!({}),
363            },
364        }
365    }
366
367    async fn outcome(level: ToolSideEffectLevel) -> ToolAuthorizationOutcome {
368        ApprovalGate::new()
369            .authorize(&request("shell", level))
370            .await
371            .expect("authorization does not error")
372            .outcome
373    }
374
375    fn approval_request() -> ApprovalRequest {
376        ApprovalRequest {
377            request_id: "r".to_string(),
378            tool_call_id: "t".to_string(),
379            tool_name: "shell".to_string(),
380            description: "d".to_string(),
381            input: json!({}),
382            side_effect_level: Some(ToolSideEffectLevel::Process),
383        }
384    }
385
386    #[test]
387    fn only_side_effects_are_consequential() {
388        assert!(!is_consequential(ToolSideEffectLevel::None));
389        assert!(is_consequential(ToolSideEffectLevel::LocalState));
390        assert!(is_consequential(ToolSideEffectLevel::Process));
391        assert!(is_consequential(ToolSideEffectLevel::External));
392    }
393
394    #[tokio::test]
395    async fn a_read_only_call_is_never_worth_asking_about() {
396        assert_eq!(
397            outcome(ToolSideEffectLevel::None).await,
398            ToolAuthorizationOutcome::Allow,
399            "prompting for reads trains people to approve without reading"
400        );
401    }
402
403    #[tokio::test]
404    async fn every_other_call_is_put_to_the_approver() {
405        for level in [
406            ToolSideEffectLevel::LocalState,
407            ToolSideEffectLevel::Process,
408            ToolSideEffectLevel::External,
409        ] {
410            assert_eq!(
411                outcome(level).await,
412                ToolAuthorizationOutcome::Prompt,
413                "{level:?} changes something outside this process"
414            );
415        }
416    }
417
418    #[tokio::test]
419    async fn the_request_says_which_tool_wants_to_run() {
420        // This text is what an approver shows a person, so a request that
421        // named nothing would be a prompt nobody can answer.
422        let decision = ApprovalGate::new()
423            .authorize(&request("files", ToolSideEffectLevel::LocalState))
424            .await
425            .expect("no error");
426
427        let reason = decision.reason.expect("a prompt must say what it is about");
428        assert!(reason.contains("files"), "{reason}");
429    }
430
431    #[test]
432    fn a_gate_waits_as_long_as_it_takes_unless_told_otherwise() {
433        assert_eq!(ApprovalGate::new().timeout(), None);
434        assert_eq!(
435            ApprovalGate::new()
436                .with_timeout(Duration::from_secs(60))
437                .timeout(),
438            Some(Duration::from_secs(60))
439        );
440    }
441
442    #[tokio::test]
443    async fn the_trivial_approvers_answer_as_named() {
444        let request = approval_request();
445
446        assert_eq!(
447            AllowAll.approve(&request).await.decision,
448            ApprovalDecision::Allow
449        );
450        assert_eq!(
451            DenyAll.approve(&request).await.decision,
452            ApprovalDecision::Deny
453        );
454    }
455
456    #[tokio::test]
457    async fn a_blanket_refusal_tells_the_model_why_it_was_refused() {
458        // Without this the model reads "denied" and tries the write again;
459        // with it, it learns the run itself is the reason and stops.
460        let reason = DenyAll
461            .approve(&approval_request())
462            .await
463            .reason
464            .expect("a refusal the model can act on must explain itself");
465
466        assert_eq!(
467            reason,
468            "shell changes state outside this process, which this run does not allow"
469        );
470    }
471
472    #[tokio::test]
473    async fn a_boxed_approver_answers_exactly_as_the_one_inside() {
474        // What the binary relies on to choose between three approvers without
475        // writing the run out three times.
476        let mut chosen: Box<dyn Approver> = Box::new(DenyAll);
477        let answer = chosen.approve(&approval_request()).await;
478
479        assert_eq!(answer.decision, ApprovalDecision::Deny);
480        assert!(
481            answer.reason.is_some(),
482            "the reason must survive the indirection too"
483        );
484    }
485
486    #[test]
487    fn an_unanswered_request_is_a_refusal() {
488        // The fail-closed rule, in the one place every approver inherits it:
489        // whatever a decision defaults to is what silence means.
490        assert_eq!(ApprovalDecision::default(), ApprovalDecision::Deny);
491        assert_eq!(
492            ApprovalAnswer::default(),
493            ApprovalAnswer::new(ApprovalDecision::Deny)
494        );
495    }
496
497    #[test]
498    fn a_reason_rides_along_without_changing_the_decision() {
499        let answer = ApprovalAnswer::from(ApprovalDecision::DenyForSession).because("no writes");
500
501        assert_eq!(answer.decision, ApprovalDecision::DenyForSession);
502        assert_eq!(answer.reason.as_deref(), Some("no writes"));
503    }
504}