Skip to main content

basis_host/
approval.rs

1//! Shared approval-mode machinery for long-lived hosts.
2//!
3//! basis itself deliberately exports the [`Approver`] trait and nothing more:
4//! "always allow" and "refuse" are ordinary approvers, and "ask" is whatever
5//! host is in charge of the run. A long-lived frontend host needs one more
6//! thing, though: a switchable three-mode policy over that trait, plus
7//! session-scoped remembered answers that clear when the policy changes.
8//!
9//! The two invariants are the ones basis-acp had to get right first and every
10//! later frontend would otherwise rebuild:
11//!
12//! - A request already put to the client is decided by the policy that was in
13//!   force when it was put. [`PolicyApprover`] therefore reads the policy once
14//!   per request, before asking, and never again for that request.
15//! - Every "for this session" answer is forgotten on any policy switch. A stale
16//!   remembered allow surviving a switch to a stricter mode would be a silent
17//!   override of the very policy switch the host just made.
18//!
19//! Both invariants live *here*, in process memory, which is what makes them
20//! enforceable: [`PolicyApprover`] keeps the "…for this session" answer in
21//! [`SessionApproval`] and hands basis a plain allow or deny, so nothing
22//! reaches mentra's persisted rule store and a policy switch can genuinely
23//! clear everything. A host that passes the raw `…ForSession` decisions
24//! through instead gets basis's own duration for them — remembered on the
25//! live session, cleared at the next attach — which is documented on
26//! `basis::ApprovalDecision`.
27//!
28//! [`PolicyGate`] is the third piece, and the one that is not about what this
29//! layer writes. Both invariants above are enforced on the *approver*, and an
30//! approver only ever sees what the runtime gate surfaced and no remembered
31//! rule already answered. That is fine for a policy that decides between
32//! asking and allowing, and not fine for one that refuses: a durable rule
33//! seeded on the store would answer ahead of it. So
34//! [`ApprovalPolicy::Never`]'s refusal is stated as an authorizer, where
35//! mentra treats it as final.
36
37use std::{
38    collections::HashMap,
39    sync::{Arc, Mutex},
40};
41
42use basis::approval::{
43    ApprovalAnswer, ApprovalDecision, ApprovalRequest, Approver, RuntimeError,
44    ToolAuthorizationDecision, ToolAuthorizationRequest, ToolAuthorizer, is_consequential,
45};
46
47/// What a host does about a consequential call.
48///
49/// The same three answers basis-acp, basis-tasks, and the CLI had each been
50/// spelling for themselves.
51#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
52#[serde(rename_all = "lowercase")]
53pub enum ApprovalPolicy {
54    /// Act without asking.
55    Always,
56    /// Ask whichever host is answering for this session.
57    #[default]
58    Prompt,
59    /// Refuse anything that changes state outside the process.
60    Never,
61}
62
63impl ApprovalPolicy {
64    /// The stable lowercase identifier shared by persisted task metadata,
65    /// protocol adapters, and command-line parsers.
66    pub const fn type_tag(self) -> &'static str {
67        match self {
68            Self::Always => "always",
69            Self::Prompt => "prompt",
70            Self::Never => "never",
71        }
72    }
73
74    /// Reads a stable lowercase identifier.
75    pub fn from_type_tag(tag: &str) -> Option<Self> {
76        match tag {
77            "always" => Some(Self::Always),
78            "prompt" => Some(Self::Prompt),
79            "never" => Some(Self::Never),
80            _ => None,
81        }
82    }
83}
84
85/// One session's active approval policy and its remembered answers.
86///
87/// Cloneable and shared: a dispatch loop may switch the mode while a spawned
88/// turn reads it. The lock is sync and never held across an await.
89#[derive(Clone)]
90pub struct SessionApproval {
91    inner: Arc<Mutex<State>>,
92}
93
94struct State {
95    current: ApprovalPolicy,
96    remembered: HashMap<String, bool>,
97}
98
99impl SessionApproval {
100    /// Opens a session at `initial`.
101    pub fn new(initial: ApprovalPolicy) -> Self {
102        Self {
103            inner: Arc::new(Mutex::new(State {
104                current: initial,
105                remembered: HashMap::new(),
106            })),
107        }
108    }
109
110    pub fn current(&self) -> ApprovalPolicy {
111        self.lock().current
112    }
113
114    /// Sets the policy and forgets every session-scoped answer.
115    pub fn set(&self, policy: ApprovalPolicy) {
116        let mut state = self.lock();
117        state.current = policy;
118        state.remembered.clear();
119    }
120
121    fn remember(&self, tool_name: &str, allow: bool) {
122        self.lock().remembered.insert(tool_name.to_string(), allow);
123    }
124
125    fn remembered(&self, tool_name: &str) -> Option<bool> {
126        self.lock().remembered.get(tool_name).copied()
127    }
128
129    fn lock(&self) -> std::sync::MutexGuard<'_, State> {
130        self.inner
131            .lock()
132            .unwrap_or_else(|poisoned| poisoned.into_inner())
133    }
134}
135
136/// Why a read-only session refused, in the words the model reads.
137///
138/// One function because two layers now refuse for the same reason —
139/// [`PolicyGate`] before the call is surfaced at all, [`PolicyApprover`] for a
140/// call some other authorizer surfaced anyway — and a model that got two
141/// different explanations of one prohibition would be reading a difference
142/// that is not there.
143fn read_only_refusal(tool_name: &str) -> String {
144    format!(
145        "{tool_name} changes state outside this process, and this session is set to refuse that"
146    )
147}
148
149/// The tool authorizer a long-lived host installs on each of its sessions:
150/// the same filter basis's [`ApprovalGate`](basis::ApprovalGate) applies, plus
151/// the one answer that has to be terminal.
152///
153/// # Why the refusal is here and the rest is not
154///
155/// `ApprovalGate` answers nothing — every consequential call comes back as a
156/// `Prompt` — and that is what lets the policy be read per call, one layer up,
157/// where `session/set_mode` arrives. mentra resolves a `Prompt` against the
158/// conversation's remembered rules *before* the approver is consulted, so a
159/// durable Global- or Project-scope allow seeded through the session's
160/// permission handle answers ahead of the policy. For [`Always`] and
161/// [`Prompt`] that is unremarkable: both permit consequential work, and a
162/// standing allow is a host saying so in advance. For [`Never`] it is a
163/// standing override of the one thing that mode promises.
164///
165/// So this gate answers [`Never`] itself, with a `Deny` mentra returns
166/// unchanged — no rule is read, no `PermissionRequested` is emitted, and the
167/// approver is never reached. Every other policy still surfaces the call
168/// exactly as `ApprovalGate` did, remembered rules and all.
169///
170/// The policy is read from the shared [`SessionApproval`] on each call, so one
171/// installed gate follows a session across every switch. It is deliberately
172/// read *per call* rather than per turn: a gate that cached the policy it was
173/// installed under would be the stale answer this whole module exists to
174/// prevent.
175///
176/// # What it does not close
177///
178/// A durable rule still answers ahead of the client under [`Always`] and
179/// [`Prompt`], because under those policies the gate still says `Prompt`.
180/// Revoking one is mentra's business
181/// ([mentra#43](https://github.com/oops-rs/mentra/issues/43)), not something a
182/// posture can express. And a rule remembered against a *read-only* tool is
183/// never consulted under any policy — this gate allows a non-consequential
184/// call outright, exactly as `ApprovalGate` does, for the reason written on
185/// [`basis::approval::is_consequential`].
186///
187/// # And why it carries no timeout
188///
189/// [`ApprovalGate`](basis::ApprovalGate) has a `with_timeout`, and installing
190/// this gate replaces the runtime's, so the obvious thing is to mirror the knob
191/// so a host can restate a bound it would otherwise lose. **That knob does not
192/// do what its name suggests, so this gate does not offer one.**
193///
194/// A `ToolAuthorizer`'s timeout is mentra's bound on mentra's own wait. When it
195/// fires, mentra drops the authorization, denies *that call*, and lets the turn
196/// carry on. It does not touch basis's side: `basis`'s event forwarder is
197/// parked inside `Approver::approve` for the same request
198/// (`basis/src/run/prepared/forward.rs`), and nothing wakes it, so a run whose
199/// approver never answers **never returns, bound or no bound** — measured, not
200/// assumed. What a bound really buys is an earlier refusal for an approver that
201/// answers *late*; what it is reached for is the approver that never answers,
202/// and there it buys nothing.
203///
204/// So the recourse for an unanswered request is the one that actually unparks
205/// the approver, and every host that installs this gate already needs it: over
206/// ACP the client sends `session/cancel` — which the protocol *requires* of a
207/// client abandoning a `session/request_permission`, and which `basis-acp`
208/// implements and pins (`tests/acp/permission.rs`) — and a host on another
209/// transport bounds its own approver, which is the layer that is genuinely
210/// waiting. Adding a knob here would have offered a guarantee that reads like
211/// that one and is not.
212///
213/// [`Always`]: ApprovalPolicy::Always
214/// [`Prompt`]: ApprovalPolicy::Prompt
215/// [`Never`]: ApprovalPolicy::Never
216/// Cloneable for the same reason [`SessionApproval`] is, and not `Debug` for
217/// the same reason either: what it holds is one session's live policy, shared.
218#[derive(Clone)]
219pub struct PolicyGate {
220    approval: SessionApproval,
221}
222
223impl PolicyGate {
224    /// Gates a session on `approval`, reading it live.
225    pub fn new(approval: SessionApproval) -> Self {
226        Self { approval }
227    }
228}
229
230#[async_trait::async_trait]
231impl ToolAuthorizer for PolicyGate {
232    async fn authorize(
233        &self,
234        request: &ToolAuthorizationRequest,
235    ) -> Result<ToolAuthorizationDecision, RuntimeError> {
236        if !is_consequential(request.preview.side_effect_level) {
237            return Ok(ToolAuthorizationDecision::allow());
238        }
239
240        Ok(match self.approval.current() {
241            ApprovalPolicy::Never => {
242                ToolAuthorizationDecision::deny(read_only_refusal(&request.tool_name))
243            }
244            // Unchanged from `ApprovalGate`, wording included: the reason
245            // becomes the description whoever is answering shows a person.
246            ApprovalPolicy::Always | ApprovalPolicy::Prompt => {
247                ToolAuthorizationDecision::prompt(format!(
248                    "{} wants to run and can change state outside this process",
249                    request.tool_name
250                ))
251            }
252        })
253    }
254}
255
256/// Applies a session's policy to each approval request, asking `inner` only
257/// when the policy says to ask.
258pub struct PolicyApprover<A> {
259    approval: SessionApproval,
260    inner: A,
261}
262
263impl<A> PolicyApprover<A> {
264    pub fn new(approval: SessionApproval, inner: A) -> Self {
265        Self { approval, inner }
266    }
267}
268
269#[async_trait::async_trait]
270impl<A: Approver> Approver for PolicyApprover<A> {
271    async fn approve(&mut self, request: &ApprovalRequest) -> ApprovalAnswer {
272        match self.approval.current() {
273            ApprovalPolicy::Always => ApprovalDecision::Allow.into(),
274            // Reached only when something other than [`PolicyGate`] surfaced
275            // the call — a source that installed its own authorizer, or a run
276            // driven without one. The gate refuses first where it is installed.
277            ApprovalPolicy::Never => ApprovalAnswer::new(ApprovalDecision::Deny)
278                .because(read_only_refusal(&request.tool_name)),
279            ApprovalPolicy::Prompt => self.ask(request).await,
280        }
281    }
282}
283
284impl<A: Approver> PolicyApprover<A> {
285    async fn ask(&mut self, request: &ApprovalRequest) -> ApprovalAnswer {
286        if let Some(allow) = self.approval.remembered(&request.tool_name) {
287            return if allow {
288                ApprovalDecision::Allow.into()
289            } else {
290                ApprovalAnswer::new(ApprovalDecision::Deny).because(format!(
291                    "{} was refused earlier in this session, and that answer still stands",
292                    request.tool_name
293                ))
294            };
295        }
296
297        let answer = self.inner.approve(request).await;
298        match answer.decision {
299            ApprovalDecision::AllowForSession => {
300                self.approval.remember(&request.tool_name, true);
301                ApprovalAnswer {
302                    decision: ApprovalDecision::Allow,
303                    ..answer
304                }
305            }
306            ApprovalDecision::DenyForSession => {
307                self.approval.remember(&request.tool_name, false);
308                ApprovalAnswer {
309                    decision: ApprovalDecision::Deny,
310                    ..answer
311                }
312            }
313            _ => answer,
314        }
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use basis::ToolSideEffectLevel;
322    use mentra::tool::{
323        ToolApprovalCategory, ToolAuthorizationOutcome, ToolAuthorizationPreview, ToolCapability,
324        ToolDurability, ToolExecutionCategory,
325    };
326    use serde_json::json;
327    use std::sync::atomic::{AtomicUsize, Ordering};
328
329    struct Counting {
330        asked: Arc<AtomicUsize>,
331        answer: ApprovalDecision,
332    }
333
334    #[async_trait::async_trait]
335    impl Approver for Counting {
336        async fn approve(&mut self, _request: &ApprovalRequest) -> ApprovalAnswer {
337            self.asked.fetch_add(1, Ordering::SeqCst);
338            self.answer.into()
339        }
340    }
341
342    fn request(tool_name: &str) -> ApprovalRequest {
343        ApprovalRequest {
344            request_id: "r1".to_string(),
345            tool_call_id: "c1".to_string(),
346            tool_name: tool_name.to_string(),
347            description: "wants to write".to_string(),
348            input: json!({}),
349            side_effect_level: Some(ToolSideEffectLevel::LocalState),
350        }
351    }
352
353    fn gate(
354        initial: ApprovalPolicy,
355        answer: ApprovalDecision,
356    ) -> (SessionApproval, PolicyApprover<Counting>, Arc<AtomicUsize>) {
357        let approval = SessionApproval::new(initial);
358        let asked = Arc::new(AtomicUsize::new(0));
359        let approver = PolicyApprover::new(
360            approval.clone(),
361            Counting {
362                asked: Arc::clone(&asked),
363                answer,
364            },
365        );
366        (approval, approver, asked)
367    }
368
369    #[test]
370    fn the_tags_round_trip() {
371        assert_eq!(ApprovalPolicy::default(), ApprovalPolicy::Prompt);
372
373        for policy in [
374            ApprovalPolicy::Always,
375            ApprovalPolicy::Prompt,
376            ApprovalPolicy::Never,
377        ] {
378            let tag = policy.type_tag();
379            assert_eq!(ApprovalPolicy::from_type_tag(tag), Some(policy));
380            assert_eq!(serde_json::to_value(policy).unwrap(), json!(tag));
381            assert_eq!(
382                serde_json::from_value::<ApprovalPolicy>(json!(tag)).unwrap(),
383                policy
384            );
385        }
386        assert_eq!(ApprovalPolicy::from_type_tag("architect"), None);
387    }
388
389    #[tokio::test]
390    async fn allow_and_refuse_answer_without_asking() {
391        for (mode, expected) in [
392            (ApprovalPolicy::Always, ApprovalDecision::Allow),
393            (ApprovalPolicy::Never, ApprovalDecision::Deny),
394        ] {
395            let (_modes, mut approver, asked) = gate(mode, ApprovalDecision::Allow);
396
397            assert_eq!(approver.approve(&request("shell")).await.decision, expected);
398            assert_eq!(
399                asked.load(Ordering::SeqCst),
400                0,
401                "{mode:?} has nothing to ask about"
402            );
403        }
404    }
405
406    #[tokio::test]
407    async fn a_read_only_session_says_so_when_it_refuses() {
408        let (_approval, mut approver, _asked) =
409            gate(ApprovalPolicy::Never, ApprovalDecision::Allow);
410
411        assert_eq!(
412            approver.approve(&request("shell")).await.reason.as_deref(),
413            Some(
414                "shell changes state outside this process, \
415                 and this session is set to refuse that"
416            )
417        );
418    }
419
420    #[tokio::test]
421    async fn asking_puts_the_request_to_the_client() {
422        let (_approval, mut approver, asked) =
423            gate(ApprovalPolicy::Prompt, ApprovalDecision::Allow);
424
425        assert_eq!(
426            approver.approve(&request("shell")).await.decision,
427            ApprovalDecision::Allow
428        );
429        assert_eq!(asked.load(Ordering::SeqCst), 1);
430    }
431
432    #[tokio::test]
433    async fn an_answer_for_the_session_is_not_asked_twice() {
434        let (_approval, mut approver, asked) =
435            gate(ApprovalPolicy::Prompt, ApprovalDecision::AllowForSession);
436
437        assert_eq!(
438            approver.approve(&request("shell")).await.decision,
439            ApprovalDecision::Allow
440        );
441        assert_eq!(
442            approver.approve(&request("shell")).await.decision,
443            ApprovalDecision::Allow
444        );
445        assert_eq!(asked.load(Ordering::SeqCst), 1);
446
447        assert_eq!(
448            approver.approve(&request("files")).await.decision,
449            ApprovalDecision::Allow
450        );
451        assert_eq!(asked.load(Ordering::SeqCst), 2);
452    }
453
454    #[tokio::test]
455    async fn changing_mode_forgets_what_was_allowed_for_the_session() {
456        let (approval, mut approver, _asked) =
457            gate(ApprovalPolicy::Prompt, ApprovalDecision::AllowForSession);
458
459        approver.approve(&request("shell")).await;
460        approval.set(ApprovalPolicy::Never);
461
462        assert_eq!(
463            approver.approve(&request("shell")).await.decision,
464            ApprovalDecision::Deny,
465            "a stale allow must not survive the mode that replaced it"
466        );
467
468        approval.set(ApprovalPolicy::Prompt);
469        assert_eq!(
470            approver.approve(&request("shell")).await.decision,
471            ApprovalDecision::Allow,
472            "moving back to prompt must ask again rather than reviving a stale rule"
473        );
474    }
475
476    fn authorization(tool_name: &str, level: ToolSideEffectLevel) -> ToolAuthorizationRequest {
477        ToolAuthorizationRequest {
478            agent_id: "a1".to_string(),
479            agent_name: "test".to_string(),
480            model: "m".to_string(),
481            history_len: 1,
482            tool_call_id: "tc-1".to_string(),
483            tool_name: tool_name.to_string(),
484            preview: ToolAuthorizationPreview {
485                working_directory: std::path::PathBuf::from("/repo"),
486                capabilities: vec![ToolCapability::FilesystemWrite],
487                side_effect_level: level,
488                durability: ToolDurability::Ephemeral,
489                execution_category: ToolExecutionCategory::default(),
490                approval_category: ToolApprovalCategory::default(),
491                raw_input: json!({}),
492                structured_input: json!({}),
493            },
494        }
495    }
496
497    async fn gated(
498        approval: &SessionApproval,
499        level: ToolSideEffectLevel,
500    ) -> ToolAuthorizationDecision {
501        PolicyGate::new(approval.clone())
502            .authorize(&authorization("shell", level))
503            .await
504            .expect("authorization does not error")
505    }
506
507    #[tokio::test]
508    async fn a_read_only_session_refuses_at_the_gate_rather_than_surfacing() {
509        // The whole point: a `Deny` here is terminal, so no remembered rule is
510        // read and the approver is never reached. A `Prompt` would put the
511        // call back where a seeded durable allow can answer it.
512        let approval = SessionApproval::new(ApprovalPolicy::Never);
513        let decision = gated(&approval, ToolSideEffectLevel::Process).await;
514
515        assert_eq!(decision.outcome, ToolAuthorizationOutcome::Deny);
516        assert_eq!(
517            decision.reason.as_deref(),
518            Some(
519                "shell changes state outside this process, \
520                 and this session is set to refuse that"
521            ),
522            "and it refuses in the same words the approver would have used"
523        );
524    }
525
526    #[tokio::test]
527    async fn every_other_policy_still_surfaces_the_call() {
528        for policy in [ApprovalPolicy::Always, ApprovalPolicy::Prompt] {
529            let approval = SessionApproval::new(policy);
530
531            assert_eq!(
532                gated(&approval, ToolSideEffectLevel::Process).await.outcome,
533                ToolAuthorizationOutcome::Prompt,
534                "{policy:?} decides per turn, and a remembered rule may answer first"
535            );
536        }
537    }
538
539    #[tokio::test]
540    async fn a_read_is_allowed_whatever_the_policy_is() {
541        // The corollary basis documents: prompting for reads trains people to
542        // approve without reading, so not even read-only surfaces one.
543        for policy in [
544            ApprovalPolicy::Always,
545            ApprovalPolicy::Prompt,
546            ApprovalPolicy::Never,
547        ] {
548            let approval = SessionApproval::new(policy);
549
550            assert_eq!(
551                gated(&approval, ToolSideEffectLevel::None).await.outcome,
552                ToolAuthorizationOutcome::Allow,
553                "{policy:?} has nothing to ask about for a read"
554            );
555        }
556    }
557
558    #[tokio::test]
559    async fn the_gate_follows_the_session_it_was_installed_on() {
560        // Installed once, before the first turn, and never replaced: a gate
561        // that cached the policy would keep answering for a mode the client
562        // has already moved off.
563        let approval = SessionApproval::new(ApprovalPolicy::Prompt);
564        let gate = PolicyGate::new(approval.clone());
565        let request = authorization("shell", ToolSideEffectLevel::Process);
566
567        assert_eq!(
568            gate.authorize(&request).await.expect("no error").outcome,
569            ToolAuthorizationOutcome::Prompt
570        );
571
572        approval.set(ApprovalPolicy::Never);
573        assert_eq!(
574            gate.authorize(&request).await.expect("no error").outcome,
575            ToolAuthorizationOutcome::Deny,
576            "the same gate must read the mode the session is on now"
577        );
578
579        approval.set(ApprovalPolicy::Always);
580        assert_eq!(
581            gate.authorize(&request).await.expect("no error").outcome,
582            ToolAuthorizationOutcome::Prompt,
583            "and must stop refusing when the session moves back"
584        );
585    }
586
587    #[test]
588    fn a_gate_waits_as_long_as_it_takes_and_offers_no_way_not_to() {
589        // Not an oversight: an authorizer's timeout bounds mentra's wait, not
590        // basis's forwarder parked in `Approver::approve`, so a knob here would
591        // read as protection against a client that never answers and give none.
592        // `PolicyGate`'s doc has the measurement; the recourse is
593        // `session/cancel`, which the interrupt tests in `basis-acp` pin.
594        assert_eq!(
595            PolicyGate::new(SessionApproval::new(ApprovalPolicy::Prompt)).timeout(),
596            None
597        );
598    }
599
600    #[tokio::test]
601    async fn a_refusal_for_the_session_is_also_remembered() {
602        let (_approval, mut approver, asked) =
603            gate(ApprovalPolicy::Prompt, ApprovalDecision::DenyForSession);
604
605        assert_eq!(
606            approver.approve(&request("shell")).await.decision,
607            ApprovalDecision::Deny
608        );
609        let repeated = approver.approve(&request("shell")).await;
610        assert_eq!(repeated.decision, ApprovalDecision::Deny);
611        assert_eq!(
612            repeated.reason.as_deref(),
613            Some("shell was refused earlier in this session, and that answer still stands")
614        );
615        assert_eq!(asked.load(Ordering::SeqCst), 1);
616    }
617}