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//! [`DenyAllGate`] is [`DenyAll`] restated as an authorizer, and not a fourth
18//! piece: a refusal is the one answer an approver cannot make stick on its own,
19//! because a remembered rule resolves the gate's `Prompt` before any approver
20//! is consulted. A run that must refuse installs both, saying the same thing in
21//! the same words at the two layers that get asked.
22//!
23//! The first of those is the one this module has to make *writable* rather than
24//! merely describable, and it is written on [`Approver`]. It reads
25//! [`ApprovalRequest::side_effect_level`] and names no tool, which is the whole
26//! point: a policy spelled as a list of tool names is a policy that silently
27//! stops covering the next MCP server a workspace connects.
28//!
29//! Nothing installs an approver by default, and that is deliberate: with no
30//! approver the run gets [`AllowAll`], which is what a headless run needs.
31//! Anything stricter is one argument to
32//! [`run_with_approver`](crate::run::run_with_approver).
33
34use std::time::Duration;
35
36use async_trait::async_trait;
37use serde_json::Value;
38
39/// How far outside this process a call reaches: nothing, this machine's state,
40/// another process, or the world.
41///
42/// mentra's, deliberately, and re-exported here under the rule written on
43/// [`CancellationToken`](crate::CancellationToken) — every mentra type basis's
44/// surface makes a caller *name*, basis re-exports. Both
45/// [`is_consequential`] and [`ApprovalRequest::side_effect_level`] ask an
46/// approver to name it, and without this line writing the policy those exist
47/// for would mean adding mentra to the host's own manifest, pinned to whatever
48/// version basis happens to resolve.
49pub use mentra::tool::ToolSideEffectLevel;
50
51/// Everything writing a [`ToolAuthorizer`] of one's own makes a caller name.
52///
53/// The same rule as [`ToolSideEffectLevel`] above, applied to the seam
54/// [`PreparedRun::with_tool_authorizer`](crate::PreparedRun::with_tool_authorizer)
55/// opened: a host that installs an authorizer on a live session has to spell
56/// the trait, the request it is handed, the decision it returns and the error
57/// it may return instead, and none of those should cost it a mentra dependency
58/// of its own — pinned to whatever version basis happens to resolve.
59///
60/// These stay in this module rather than joining [`ApprovalGate`] at the crate
61/// root. `RuntimeError` is mentra's runtime error and cannot sit unqualified
62/// beside basis's own [`RunError`](crate::RunError) without the two reading as
63/// a pair, and the five are only meaningful together — an authorizer names all
64/// of them or none.
65pub use mentra::{
66 error::RuntimeError,
67 tool::{
68 ToolAuthorizationDecision, ToolAuthorizationOutcome, ToolAuthorizationRequest,
69 ToolAuthorizer,
70 },
71};
72
73/// What the agent wants to do, as put to an [`Approver`].
74///
75/// Deliberately not `#[non_exhaustive]`, though hosts read it far more often
76/// than they build one. The struct has no constructor and no builder, so
77/// sealing it would make an `ApprovalRequest` *unconstructable* outside this
78/// crate — and every host testing its own approver builds one, as `basis-acp`
79/// and `basis-cli` both do. Sealing would trade a compile error that names the
80/// new field, on the day a field is added, for a permanent one with no way past
81/// it.
82#[derive(Debug, Clone, PartialEq)]
83pub struct ApprovalRequest {
84 pub request_id: String,
85 pub tool_call_id: String,
86 pub tool_name: String,
87 /// Why approval is being asked for.
88 pub description: String,
89 /// The tool's input, parsed when it is JSON.
90 pub input: Value,
91 /// How far outside this process the call reaches, when basis knows.
92 ///
93 /// This is what lets a policy be written about *what a call does* rather
94 /// than about which tools happen to be installed —
95 /// [`LocalState`](ToolSideEffectLevel::LocalState) for an edit to this
96 /// checkout, [`External`](ToolSideEffectLevel::External) for an MCP server
97 /// or a declared tool that leaves the machine. See [`Approver`] for the
98 /// worked example.
99 ///
100 /// **`None` means unknown, never harmless.** Read-only calls do not reach
101 /// an approver at all ([`is_consequential`]), so nothing that arrives here
102 /// is a read; a `None` is only ever basis failing to recover a fact it
103 /// could not carry. The fail-closed reading is to treat it as
104 /// [`External`](ToolSideEffectLevel::External) — judge it by the most it
105 /// could be doing, the same rule the rest of this module runs on.
106 ///
107 /// It is an `Option` because mentra types the classification as one:
108 /// since [mentra#21](https://github.com/oops-rs/mentra/issues/21) the
109 /// `PermissionRequested` event carries the call's classification, and
110 /// mentra documents it as always present on a live request — but that is
111 /// mentra's invariant to keep, not basis's to unwrap, so basis passes on
112 /// what the event says. An approver reads `None` by the rule above:
113 /// unknown, never harmless.
114 pub side_effect_level: Option<ToolSideEffectLevel>,
115}
116
117/// What an [`Approver`] decided.
118///
119/// # How long "for the session" lasts
120///
121/// A `…ForSession` answer is remembered against the conversation and answers
122/// every later call of that tool on the **live session**: further calls in the
123/// same turn, and further runs on the same [`PreparedRun`](crate::PreparedRun)
124/// in the same process. It dies at the next attach — resuming the
125/// conversation ([`Workspace::resume`](crate::Workspace)) starts it with none
126/// remembered, so a later process that picks the conversation back up asks
127/// again rather than replaying an answer nobody in that process gave.
128///
129/// That boundary is inherent now, not enforced. mentra 0.27's
130/// `PermissionRuleScope::Process` (mentra#53) is what basis remembers a
131/// `…ForSession` answer into: a rung owned by the live session's own
132/// permission handle, never written to the runtime store, so a resumed
133/// session — a fresh handle, even for the same conversation — simply starts
134/// with an empty one. Nothing basis runs has to clear it. (One resume-time
135/// clear does still exist, in `Runtime::resume_minted`, but it is a one-way
136/// migration for rows a pre-0.12 basis binary remembered into the durable
137/// `Session` scope before this change; it plays no part in the guarantee
138/// documented here for an answer given today.)
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
140pub enum ApprovalDecision {
141 Allow,
142 /// The default: when in doubt, do not.
143 #[default]
144 Deny,
145 /// Allow, and stop asking about this tool for the rest of the live
146 /// session — see the duration rule above.
147 AllowForSession,
148 /// Deny, and stop asking about this tool for the rest of the live
149 /// session — see the duration rule above.
150 DenyForSession,
151}
152
153/// How an [`Approver`] answered: the decision, and — when it refused — why.
154///
155/// The reason is not decoration. A denial reaches the model as that tool
156/// call's result, so the wording is the only thing telling it what to do
157/// next: a model told merely that something was denied tries the write
158/// again, and one told this run does not allow writes stops and reports.
159/// An answer that leaves it unset still denies; the model just reads
160/// mentra's standing "denied by session approver" instead.
161///
162/// Allowing needs no reason, because an allowed call explains itself by
163/// happening.
164#[derive(Debug, Clone, PartialEq, Eq, Default)]
165pub struct ApprovalAnswer {
166 pub decision: ApprovalDecision,
167 pub reason: Option<String>,
168}
169
170impl ApprovalAnswer {
171 /// An answer that says only what it decided.
172 pub fn new(decision: ApprovalDecision) -> Self {
173 Self {
174 decision,
175 reason: None,
176 }
177 }
178
179 /// The same answer, carrying the words the model will read.
180 pub fn because(self, reason: impl Into<String>) -> Self {
181 Self {
182 reason: Some(reason.into()),
183 ..self
184 }
185 }
186}
187
188impl From<ApprovalDecision> for ApprovalAnswer {
189 fn from(decision: ApprovalDecision) -> Self {
190 Self::new(decision)
191 }
192}
193
194/// Answers approval requests. The seam a host plugs its own judgment into.
195///
196/// Called from the event-forwarding task while the turn is blocked inside
197/// mentra waiting, so an implementation must answer rather than defer to
198/// something that only happens after the run.
199///
200/// Async because answering genuinely takes time and the caller is an async
201/// task: an ACP approver awaits a round trip to the client, and a terminal one
202/// waits on a person. A synchronous signature would force both to block a
203/// runtime worker thread — which tokio rejects outright for the ACP case. The
204/// attribute to spell an impl with is re-exported at the crate root —
205/// [`async_trait`](crate::async_trait) — so it costs no manifest line of the
206/// host's own.
207///
208/// # Fail closed
209///
210/// **An approver that cannot answer denies.** No terminal to ask at, an answer
211/// that never came, a channel whose other end is gone: none of those is
212/// consent, and the only calls that reach an approver are the ones that change
213/// something outside this process.
214///
215/// The worked example is the binary's `TerminalApprover`. Asked when stdin is
216/// not a terminal — an unattended `basis spawn --approve prompt`, a cron job — it
217/// denies without printing a question nobody would read, so the run fails
218/// visibly instead of quietly granting whatever came up. `basis-acp`'s client
219/// approver applies the same rule to a failed round trip, a cancelled request,
220/// an answer it cannot parse, and its own thirty-minute timeout.
221///
222/// Each of those denials should say which one it was, on the
223/// [`reason`](ApprovalAnswer::reason) of its answer. Failing closed silently
224/// leaves the model to guess, and it guesses that retrying will work.
225///
226/// [`ApprovalDecision`]'s own default is [`Deny`](ApprovalDecision::Deny) for
227/// the same reason, and so is mentra's when an authorizer times out: silence is
228/// never a yes.
229///
230/// # Allow edits, deny the network
231///
232/// The policy this module's own documentation has always named as the reason
233/// the seam is a trait, written out. Nothing in it names a tool — every call is
234/// judged by how far it reaches — so a workspace that connects a new MCP server
235/// tomorrow, or ships a `.basis/tools.json` declaring a program, is covered by
236/// the rule that was already there.
237///
238/// ```
239/// use basis::{
240/// ApprovalAnswer, ApprovalDecision, ApprovalRequest, Approver, ToolSideEffectLevel,
241/// async_trait,
242/// };
243///
244/// struct EditsButNotTheNetwork;
245///
246/// #[async_trait]
247/// impl Approver for EditsButNotTheNetwork {
248/// async fn approve(&mut self, request: &ApprovalRequest) -> ApprovalAnswer {
249/// match request.side_effect_level {
250/// // Changes this machine's state and nothing past it: the file
251/// // tools, and a delegation to a subagent.
252/// Some(ToolSideEffectLevel::LocalState) => ApprovalDecision::Allow.into(),
253///
254/// // Everything else. `Process` is a command, which can reach the
255/// // network by running `curl`; `External` says so outright; and
256/// // `None` is a level basis could not recover, which is judged by
257/// // the most it could be rather than the least. `ToolSideEffectLevel::None`
258/// // never arrives — a read is not put to an approver at all.
259/// _ => ApprovalAnswer::new(ApprovalDecision::Deny)
260/// .because("this run may change this checkout and nothing beyond it"),
261/// }
262/// }
263/// }
264/// ```
265#[async_trait]
266pub trait Approver: Send + 'static {
267 async fn approve(&mut self, request: &ApprovalRequest) -> ApprovalAnswer;
268}
269
270/// Forwards to the approver inside.
271///
272/// Lets a caller hold an approver it chose at runtime — one of several, or one
273/// a feature flag picked — and still pass it to anything taking
274/// `impl Approver`. The binary is exactly that caller: `--approve` names one of
275/// three, and without this each arm would have to duplicate the whole run.
276#[async_trait]
277impl<A: Approver + ?Sized> Approver for Box<A> {
278 async fn approve(&mut self, request: &ApprovalRequest) -> ApprovalAnswer {
279 (**self).approve(request).await
280 }
281}
282
283/// Approves everything. What a confined or headless run wants, and what a run
284/// given no approver of its own gets.
285#[derive(Debug, Default, Clone, Copy)]
286pub struct AllowAll;
287
288#[async_trait]
289impl Approver for AllowAll {
290 async fn approve(&mut self, _request: &ApprovalRequest) -> ApprovalAnswer {
291 ApprovalDecision::Allow.into()
292 }
293}
294
295/// Why a refusing run refused, in the words the model reads.
296///
297/// One function because two layers refuse for the same reason — [`DenyAllGate`]
298/// before the call is surfaced at all, [`DenyAll`] for a call some other
299/// authorizer surfaced anyway — and a model given two different explanations of
300/// one prohibition would be reading a difference that is not there.
301fn refusal(tool_name: &str) -> String {
302 format!("{tool_name} changes state outside this process, which this run does not allow")
303}
304
305/// Refuses everything, so the agent can inspect a workspace and report on it
306/// and cannot touch it. Each refusal reaches the model as a tool error, which
307/// is how it learns to stop trying.
308///
309/// **An approver alone is not a refusal a durable rule cannot outrank**, for
310/// the reason written on [`ApprovalGate`]: a remembered allow resolves the
311/// gate's `Prompt` before an approver is ever consulted. A run whose refusal
312/// is the whole point pairs this with [`DenyAllGate`], which states the same
313/// refusal where mentra treats it as final.
314#[derive(Debug, Default, Clone, Copy)]
315pub struct DenyAll;
316
317#[async_trait]
318impl Approver for DenyAll {
319 async fn approve(&mut self, request: &ApprovalRequest) -> ApprovalAnswer {
320 ApprovalAnswer::new(ApprovalDecision::Deny).because(refusal(&request.tool_name))
321 }
322}
323
324/// [`DenyAll`]'s refusal, stated one layer down as a tool authorizer, where no
325/// remembered rule can answer over it.
326///
327/// # Why the same refusal twice
328///
329/// [`ApprovalGate`] answers nothing: every consequential call comes back as a
330/// `Prompt`, and mentra resolves a `Prompt` against the conversation's
331/// remembered rules *before* the approver is consulted. For a policy choosing
332/// between asking and allowing that ordering is only a host saying yes in
333/// advance. For [`DenyAll`] it is a standing override — a Global- or
334/// Project-scope allow seeded through
335/// [`session()`](crate::PreparedRun::session)`.permission_handle()` answers
336/// ahead of the refusal, and outlives everything a session-scoped mechanism
337/// clears.
338///
339/// Installed with
340/// [`PreparedRun::with_tool_authorizer`](crate::PreparedRun::with_tool_authorizer),
341/// this refuses first instead. mentra returns an authorizer's `Deny`
342/// unchanged: no rule is read, no `PermissionRequested` is emitted, and the
343/// approver is never reached — so the model gets exactly the refusal
344/// [`DenyAll`] would have given it, in the same words.
345///
346/// # What it still allows
347///
348/// A non-consequential call, outright — [`ApprovalGate`]'s
349/// [`is_consequential`] filter, unchanged and deliberately so. A gate that
350/// denied reads would stop a refusing run from doing the one thing it is for,
351/// which is looking. The corollary [`ApprovalGate`] documents holds here too:
352/// a rule remembered against a read-only tool is never consulted.
353///
354/// # Which runs want it
355///
356/// One whose refusal is fixed for its whole life — a headless `--approve
357/// never` invocation, a task recorded to refuse — where there is no live mode
358/// for a stateful gate to read. A host whose posture can *change* mid-session
359/// wants one that reads that state per call instead; `basis-host`'s
360/// `PolicyGate` is that, and installing this one would freeze the mode a
361/// session opened in.
362#[derive(Debug, Default, Clone, Copy)]
363pub struct DenyAllGate;
364
365#[async_trait]
366impl ToolAuthorizer for DenyAllGate {
367 async fn authorize(
368 &self,
369 request: &ToolAuthorizationRequest,
370 ) -> Result<ToolAuthorizationDecision, RuntimeError> {
371 if !is_consequential(request.preview.side_effect_level) {
372 return Ok(ToolAuthorizationDecision::allow());
373 }
374
375 Ok(ToolAuthorizationDecision::deny(refusal(&request.tool_name)))
376 }
377}
378
379/// Whether a call changes anything outside this process.
380///
381/// Read-only calls are never worth asking about — prompting for them trains
382/// people to approve without reading, which is worse than not asking.
383pub fn is_consequential(level: ToolSideEffectLevel) -> bool {
384 !matches!(level, ToolSideEffectLevel::None)
385}
386
387/// Puts every consequential call to the [`Approver`], and lets the rest
388/// through.
389///
390/// This is the runtime half of approval, installed as mentra's
391/// `ToolAuthorizer`. It carries no policy: since ADR-0010 there is nothing left
392/// for one to say, because the approver decides. What it still owns is the
393/// filter — [`is_consequential`] — and the choice to *surface* rather than
394/// answer, which is what turns a call into a `PermissionRequested` event and
395/// blocks the turn until someone resolves it.
396///
397/// Installed even by a run that approves everything, and that is the point.
398/// basis installs it when the runtime is built, and a runtime with no
399/// authorizer at all allows every call unconditionally — no permission
400/// request can ever be raised, and since mentra 0.26 not even a remembered
401/// rule is read first. Surfacing unconditionally is what lets the answer be
402/// chosen per turn — or changed mid-session, which is how an ACP client's
403/// mode picker works at all.
404///
405/// # When a session replaces it
406///
407/// Surfacing everything has one cost, and it is the reason
408/// [`PreparedRun::with_tool_authorizer`](crate::PreparedRun::with_tool_authorizer)
409/// exists: a `Prompt` is resolved by a remembered rule *before* the approver
410/// is consulted, so a refusal written on the approver can be pre-empted by a
411/// durable allow someone seeded through the session's permission handle. For
412/// a policy that chooses between asking and allowing that is only a host
413/// saying yes in advance. For one that refuses outright it is a standing
414/// override.
415///
416/// So a host with a refusal that must outrank a remembered answer installs it
417/// as a *session* authorizer instead, where mentra treats a `Deny` as final.
418/// Two shapes of that ship, for the two shapes of host:
419///
420/// - A posture fixed for the run's whole life installs [`DenyAllGate`]. The
421/// attended CLI's `--approve never` and a task recorded to refuse both do,
422/// beside the [`DenyAll`] they were already passing.
423/// - A posture that can change mid-session installs one that reads the live
424/// state per call. `basis-acp` does, on every session it opens, with
425/// `basis-host`'s `PolicyGate`: read-only refuses, and every other mode is
426/// this gate verbatim — `Allow` for a read, `Prompt` for everything else,
427/// remembered rules and all.
428///
429/// Every other policy, and every run that installs nothing, is served by this
430/// gate on the runtime exactly as before.
431///
432/// # The gate answers first, and its answers are final
433///
434/// mentra 0.26 samples the current authorizer once per call and treats
435/// `Allow` and `Deny` as terminal; only a `Prompt` may be answered by a
436/// remembered rule or forwarded to the approver. That is a deliberate
437/// upstream security fix — a remembered allow can no longer bypass a session
438/// switched to a stricter authorizer — and basis adopts it as documented
439/// order: **rules and approver decide only what this gate surfaces.**
440///
441/// The corollary is loud because it is easy to miss: this gate answers
442/// `Allow` — not `Prompt` — for a call with no side effects
443/// ([`is_consequential`]), so **a rule remembered against a read-only tool is
444/// never consulted, a seeded deny included**. The tool runs, with no error
445/// and no event saying the rule was passed over. A host seeding rules through
446/// the session permission handle must seed them for consequential tools only;
447/// nothing basis documents promises deny-to-win on a non-consequential call,
448/// and reads are deliberately never put to anyone (see [`is_consequential`]).
449#[derive(Debug, Default, Clone)]
450pub struct ApprovalGate {
451 timeout: Option<Duration>,
452}
453
454impl ApprovalGate {
455 pub fn new() -> Self {
456 Self {
457 // No timeout by default: a person reading a diff should not lose
458 // the turn to a stopwatch. A host that needs one sets it.
459 timeout: None,
460 }
461 }
462
463 /// Gives up on an unanswered request after `timeout`, **denying that call**
464 /// — the fail-closed rule of [`Approver`], enforced from outside for an
465 /// approver that answers late.
466 ///
467 /// # What it does not bound
468 ///
469 /// Not the run. This is mentra's bound on mentra's own wait: when it fires,
470 /// mentra drops the authorization, fails the call, and carries the turn on
471 /// — while basis's event forwarder is still parked inside
472 /// [`Approver::approve`] for that same request, and nothing here wakes it.
473 /// So an approver that answers *late* is bounded, and one that **never
474 /// answers leaves the run hanging with this set exactly as without it**.
475 ///
476 /// An approver that might never answer has to bound itself, or be woken by
477 /// whatever it is waiting on: `basis-acp`'s is woken by `session/cancel`,
478 /// which ACP requires of a client abandoning a permission request. Setting
479 /// this instead would leave that run hanging on a promise it reads as
480 /// having made.
481 pub fn with_timeout(mut self, timeout: Duration) -> Self {
482 self.timeout = Some(timeout);
483 self
484 }
485}
486
487#[async_trait]
488impl ToolAuthorizer for ApprovalGate {
489 async fn authorize(
490 &self,
491 request: &ToolAuthorizationRequest,
492 ) -> Result<ToolAuthorizationDecision, RuntimeError> {
493 if !is_consequential(request.preview.side_effect_level) {
494 return Ok(ToolAuthorizationDecision::allow());
495 }
496
497 // The reason becomes the description the approver shows, so it says
498 // what is being asked rather than that something is.
499 Ok(ToolAuthorizationDecision::prompt(format!(
500 "{} wants to run and can change state outside this process",
501 request.tool_name
502 )))
503 }
504
505 fn timeout(&self) -> Option<Duration> {
506 self.timeout
507 }
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513 use mentra::tool::{
514 ToolApprovalCategory, ToolAuthorizationOutcome, ToolAuthorizationPreview, ToolCapability,
515 ToolDurability, ToolExecutionCategory,
516 };
517 use serde_json::json;
518 use std::path::PathBuf;
519
520 fn request(name: &str, level: ToolSideEffectLevel) -> ToolAuthorizationRequest {
521 ToolAuthorizationRequest {
522 agent_id: "a1".to_string(),
523 agent_name: "test".to_string(),
524 model: "m".to_string(),
525 history_len: 1,
526 tool_call_id: "tc-1".to_string(),
527 tool_name: name.to_string(),
528 preview: ToolAuthorizationPreview {
529 working_directory: PathBuf::from("/repo"),
530 capabilities: vec![ToolCapability::FilesystemWrite],
531 side_effect_level: level,
532 durability: ToolDurability::Ephemeral,
533 execution_category: ToolExecutionCategory::default(),
534 approval_category: ToolApprovalCategory::default(),
535 raw_input: json!({}),
536 structured_input: json!({}),
537 },
538 }
539 }
540
541 async fn outcome(level: ToolSideEffectLevel) -> ToolAuthorizationOutcome {
542 ApprovalGate::new()
543 .authorize(&request("shell", level))
544 .await
545 .expect("authorization does not error")
546 .outcome
547 }
548
549 fn approval_request() -> ApprovalRequest {
550 ApprovalRequest {
551 request_id: "r".to_string(),
552 tool_call_id: "t".to_string(),
553 tool_name: "shell".to_string(),
554 description: "d".to_string(),
555 input: json!({}),
556 side_effect_level: Some(ToolSideEffectLevel::Process),
557 }
558 }
559
560 #[test]
561 fn only_side_effects_are_consequential() {
562 assert!(!is_consequential(ToolSideEffectLevel::None));
563 assert!(is_consequential(ToolSideEffectLevel::LocalState));
564 assert!(is_consequential(ToolSideEffectLevel::Process));
565 assert!(is_consequential(ToolSideEffectLevel::External));
566 }
567
568 #[tokio::test]
569 async fn a_read_only_call_is_never_worth_asking_about() {
570 assert_eq!(
571 outcome(ToolSideEffectLevel::None).await,
572 ToolAuthorizationOutcome::Allow,
573 "prompting for reads trains people to approve without reading"
574 );
575 }
576
577 #[tokio::test]
578 async fn every_other_call_is_put_to_the_approver() {
579 for level in [
580 ToolSideEffectLevel::LocalState,
581 ToolSideEffectLevel::Process,
582 ToolSideEffectLevel::External,
583 ] {
584 assert_eq!(
585 outcome(level).await,
586 ToolAuthorizationOutcome::Prompt,
587 "{level:?} changes something outside this process"
588 );
589 }
590 }
591
592 #[tokio::test]
593 async fn the_request_says_which_tool_wants_to_run() {
594 // This text is what an approver shows a person, so a request that
595 // named nothing would be a prompt nobody can answer.
596 let decision = ApprovalGate::new()
597 .authorize(&request("files", ToolSideEffectLevel::LocalState))
598 .await
599 .expect("no error");
600
601 let reason = decision.reason.expect("a prompt must say what it is about");
602 assert!(reason.contains("files"), "{reason}");
603 }
604
605 #[test]
606 fn a_gate_waits_as_long_as_it_takes_unless_told_otherwise() {
607 assert_eq!(ApprovalGate::new().timeout(), None);
608 assert_eq!(
609 ApprovalGate::new()
610 .with_timeout(Duration::from_secs(60))
611 .timeout(),
612 Some(Duration::from_secs(60))
613 );
614 }
615
616 #[tokio::test]
617 async fn the_trivial_approvers_answer_as_named() {
618 let request = approval_request();
619
620 assert_eq!(
621 AllowAll.approve(&request).await.decision,
622 ApprovalDecision::Allow
623 );
624 assert_eq!(
625 DenyAll.approve(&request).await.decision,
626 ApprovalDecision::Deny
627 );
628 }
629
630 #[tokio::test]
631 async fn a_blanket_refusal_tells_the_model_why_it_was_refused() {
632 // Without this the model reads "denied" and tries the write again;
633 // with it, it learns the run itself is the reason and stops.
634 let reason = DenyAll
635 .approve(&approval_request())
636 .await
637 .reason
638 .expect("a refusal the model can act on must explain itself");
639
640 assert_eq!(
641 reason,
642 "shell changes state outside this process, which this run does not allow"
643 );
644 }
645
646 async fn refused(level: ToolSideEffectLevel) -> ToolAuthorizationDecision {
647 DenyAllGate
648 .authorize(&request("shell", level))
649 .await
650 .expect("authorization does not error")
651 }
652
653 #[tokio::test]
654 async fn a_refusing_run_refuses_at_the_gate_rather_than_surfacing() {
655 // The whole point: a `Deny` here is terminal, so no remembered rule is
656 // read and the approver is never reached. A `Prompt` would put the call
657 // back where a seeded durable allow can answer it.
658 for level in [
659 ToolSideEffectLevel::LocalState,
660 ToolSideEffectLevel::Process,
661 ToolSideEffectLevel::External,
662 ] {
663 assert_eq!(
664 refused(level).await.outcome,
665 ToolAuthorizationOutcome::Deny,
666 "{level:?} changes something outside this process"
667 );
668 }
669 }
670
671 #[tokio::test]
672 async fn a_refusing_gate_refuses_in_the_approvers_words() {
673 // Two layers, one prohibition: a model told one thing by the gate and
674 // another by `DenyAll` would be reading a difference that is not there.
675 let refusal = refused(ToolSideEffectLevel::Process)
676 .await
677 .reason
678 .expect("a refusal the model can act on must explain itself");
679
680 assert_eq!(
681 refusal,
682 DenyAll
683 .approve(&approval_request())
684 .await
685 .reason
686 .expect("`DenyAll` explains itself too")
687 );
688 }
689
690 #[tokio::test]
691 async fn a_refusing_gate_still_lets_a_read_through() {
692 // A run that refuses is for looking, so the one thing it must keep
693 // doing is looking. `ApprovalGate`'s filter, unchanged.
694 assert_eq!(
695 refused(ToolSideEffectLevel::None).await.outcome,
696 ToolAuthorizationOutcome::Allow
697 );
698 }
699
700 #[test]
701 fn a_refusing_gate_never_waits_and_so_needs_no_bound() {
702 // No `with_timeout` beside `ApprovalGate`'s, and nothing to add one
703 // for: this gate answers from the request alone and awaits nobody.
704 assert_eq!(DenyAllGate.timeout(), None);
705 }
706
707 #[tokio::test]
708 async fn a_boxed_approver_answers_exactly_as_the_one_inside() {
709 // What the binary relies on to choose between three approvers without
710 // writing the run out three times.
711 let mut chosen: Box<dyn Approver> = Box::new(DenyAll);
712 let answer = chosen.approve(&approval_request()).await;
713
714 assert_eq!(answer.decision, ApprovalDecision::Deny);
715 assert!(
716 answer.reason.is_some(),
717 "the reason must survive the indirection too"
718 );
719 }
720
721 #[test]
722 fn an_unanswered_request_is_a_refusal() {
723 // The fail-closed rule, in the one place every approver inherits it:
724 // whatever a decision defaults to is what silence means.
725 assert_eq!(ApprovalDecision::default(), ApprovalDecision::Deny);
726 assert_eq!(
727 ApprovalAnswer::default(),
728 ApprovalAnswer::new(ApprovalDecision::Deny)
729 );
730 }
731
732 #[test]
733 fn a_reason_rides_along_without_changing_the_decision() {
734 let answer = ApprovalAnswer::from(ApprovalDecision::DenyForSession).because("no writes");
735
736 assert_eq!(answer.decision, ApprovalDecision::DenyForSession);
737 assert_eq!(answer.reason.as_deref(), Some("no writes"));
738 }
739}