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}