Skip to main content

basis_acp/
mode.rs

1//! The session's permission mode, and the client's switch for it.
2//!
3//! ACP lets an agent name its own modes and lets a client change them mid
4//! session. basis offers three — [`ApprovalMode`]: always allow, ask, refuse —
5//! spelled with the same three words as `basis --approve`, so the protocol and
6//! the command line name one set of things.
7//!
8//! # Why the modes live here and not in the core
9//!
10//! basis has no such enum. Approval there is the
11//! [`Approver`] trait alone (ADR-0010): "always allow" is
12//! [`AllowAll`](basis::AllowAll), "refuse" is [`DenyAll`](basis::DenyAll),
13//! and "ask" is whatever the host installs. What ACP needs that a trait cannot
14//! give it is an *enumerable* set — `session/new` reports every available mode
15//! with an id, a name and a description, and the client picks one by id. That
16//! list is a protocol binding, so it belongs with the protocol.
17//!
18//! # Where the mode is applied, and why not on the authorizer
19//!
20//! mentra takes a `ToolAuthorizer` when a runtime is built and never hands it
21//! back, so a decision made there is fixed for the session's life — which is
22//! precisely what a switchable mode cannot be. basis's
23//! [`ApprovalGate`](basis::approval::ApprovalGate) therefore surfaces every
24//! consequential call without answering any, and the mode decides *here*, where
25//! it can still change between one call and the next.
26//!
27//! That is why [`ModedApprover`] wraps the approver that asks the client rather
28//! than replacing it: `Always` and `Never` answer without asking, and `Prompt`
29//! asks.
30//!
31//! # Why basis remembers "for this session" itself
32//!
33//! mentra can remember a decision — `PermissionDecision::allow_and_remember` —
34//! and its rule store is consulted *before* the authorizer runs. A rule stored
35//! there would survive a switch to a stricter mode and silently override it:
36//! someone who allowed `shell` for the session and then moved to read-only
37//! would still be running commands. So [`ModedApprover`] answers mentra with a
38//! plain allow or deny and keeps the "…for this session" answer here, where
39//! changing the mode clears it.
40
41use std::{
42    collections::HashMap,
43    sync::{Arc, Mutex},
44};
45
46use agent_client_protocol::schema::v1::{SessionMode, SessionModeId, SessionModeState};
47
48use basis::approval::{ApprovalAnswer, ApprovalDecision, ApprovalRequest, Approver};
49
50/// What a session does about a call that changes state outside the process.
51///
52/// The three answers a person at a client can hold an opinion about. Each is a
53/// way of answering an [`Approver`]'s question rather than a policy the runtime
54/// enforces — see the module docs on where the mode is applied.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
56pub enum ApprovalMode {
57    /// Act without asking.
58    Always,
59    /// Ask the client, every time. The default over ACP: there is a client to
60    /// ask, which is the whole reason the protocol carries a permission
61    /// request.
62    #[default]
63    Prompt,
64    /// Refuse, so the session can read a workspace and report and cannot touch
65    /// it.
66    Never,
67}
68
69/// Mode ids on the wire. basis chooses them, a client echoes them back, and
70/// [`mode_for`] reads them — a contract with ourselves, so it lives in one
71/// place.
72const ALWAYS: &str = "always";
73const PROMPT: &str = "prompt";
74const NEVER: &str = "never";
75
76/// The mode an id selects, or `None` for an id basis never offered.
77fn mode_for(id: &str) -> Option<ApprovalMode> {
78    match id {
79        ALWAYS => Some(ApprovalMode::Always),
80        PROMPT => Some(ApprovalMode::Prompt),
81        NEVER => Some(ApprovalMode::Never),
82        _ => None,
83    }
84}
85
86fn mode_id(mode: ApprovalMode) -> SessionModeId {
87    SessionModeId::new(match mode {
88        ApprovalMode::Always => ALWAYS,
89        ApprovalMode::Prompt => PROMPT,
90        ApprovalMode::Never => NEVER,
91    })
92}
93
94/// How each mode is described in a client's picker.
95fn describe(mode: ApprovalMode) -> SessionMode {
96    let (name, description) = match mode {
97        ApprovalMode::Always => (
98            "Always allow",
99            "Act without asking. What a confined or unattended session wants.",
100        ),
101        ApprovalMode::Prompt => (
102            "Ask each time",
103            "Ask before anything that changes state outside this process.",
104        ),
105        ApprovalMode::Never => (
106            "Read only",
107            "Refuse anything that changes state outside this process.",
108        ),
109    };
110
111    SessionMode::new(mode_id(mode), name).description(description)
112}
113
114/// Why a `session/set_mode` was refused.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum ModeError {
117    /// An id basis never offered.
118    Unknown,
119    /// A real mode, but not one this session may move to.
120    NotOffered,
121}
122
123impl std::fmt::Display for ModeError {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        match self {
126            Self::Unknown => f.write_str("unknown mode"),
127            Self::NotOffered => {
128                f.write_str("this session was opened read-only and cannot change mode")
129            }
130        }
131    }
132}
133
134/// One session's mode, and what the client has already answered under it.
135///
136/// Cloneable and shared: the dispatch loop sets it while a spawned turn reads
137/// it. The lock is sync and never held across an await, for the same reason
138/// the cancellation token's is — `session/set_mode` arrives *during* a turn,
139/// and ACP says explicitly that it may.
140#[derive(Clone)]
141pub struct SessionModes {
142    inner: Arc<Mutex<State>>,
143}
144
145struct State {
146    current: ApprovalMode,
147    /// False when the session was opened read-only. See [`SessionModes::new`].
148    switchable: bool,
149    /// Tools the client answered "…for this session" about, and how.
150    remembered: HashMap<String, bool>,
151}
152
153impl SessionModes {
154    /// Opens a session at `initial`.
155    ///
156    /// A session that starts at [`ApprovalMode::Never`] offers no other mode.
157    /// The other two both permit consequential work and differ only in
158    /// ceremony, so moving between them is the person at the client changing
159    /// their mind — the same authority they already exercise by answering a
160    /// permission request. `Never` is a prohibition the operator set outside
161    /// the protocol, and a client cannot lift what it was never given.
162    pub fn new(initial: ApprovalMode) -> Self {
163        Self {
164            inner: Arc::new(Mutex::new(State {
165                current: initial,
166                switchable: !matches!(initial, ApprovalMode::Never),
167                remembered: HashMap::new(),
168            })),
169        }
170    }
171
172    pub fn current(&self) -> ApprovalMode {
173        self.lock().current
174    }
175
176    /// The modes and the current one, as `session/new` and `session/load`
177    /// report them.
178    pub fn state(&self) -> SessionModeState {
179        let state = self.lock();
180        let available = if state.switchable {
181            vec![
182                describe(ApprovalMode::Always),
183                describe(ApprovalMode::Prompt),
184                describe(ApprovalMode::Never),
185            ]
186        } else {
187            vec![describe(state.current)]
188        };
189
190        SessionModeState::new(mode_id(state.current), available)
191    }
192
193    /// Switches to `id`, returning the mode now in force.
194    ///
195    /// Every "…for this session" answer is forgotten: choosing a mode is a
196    /// statement about how the rest of the session should behave, and a stale
197    /// allow that outlived it would be exactly the override this design exists
198    /// to prevent.
199    pub fn set(&self, id: &SessionModeId) -> Result<ApprovalMode, ModeError> {
200        let mode = mode_for(&id.0).ok_or(ModeError::Unknown)?;
201
202        let mut state = self.lock();
203        if !state.switchable && mode != state.current {
204            return Err(ModeError::NotOffered);
205        }
206
207        state.current = mode;
208        state.remembered.clear();
209        Ok(mode)
210    }
211
212    fn remember(&self, tool_name: &str, allow: bool) {
213        self.lock().remembered.insert(tool_name.to_string(), allow);
214    }
215
216    fn remembered(&self, tool_name: &str) -> Option<bool> {
217        self.lock().remembered.get(tool_name).copied()
218    }
219
220    fn lock(&self) -> std::sync::MutexGuard<'_, State> {
221        // A poisoned mode means some other task panicked mid-update. The mode
222        // itself is a plain enum and a map; refusing to serve the rest of the
223        // session over it would turn one panic into a dead conversation.
224        self.inner
225            .lock()
226            .unwrap_or_else(|poisoned| poisoned.into_inner())
227    }
228}
229
230/// Applies the session's mode to each approval request, asking `inner` only
231/// when the mode says to ask.
232pub struct ModedApprover<A> {
233    modes: SessionModes,
234    inner: A,
235}
236
237impl<A> ModedApprover<A> {
238    pub fn new(modes: SessionModes, inner: A) -> Self {
239        Self { modes, inner }
240    }
241}
242
243#[async_trait::async_trait]
244impl<A: Approver> Approver for ModedApprover<A> {
245    async fn approve(&mut self, request: &ApprovalRequest) -> ApprovalAnswer {
246        // Read-only calls never reach here: basis's gate allows them
247        // outright, because prompting for reads trains people to approve
248        // without reading.
249        match self.modes.current() {
250            ApprovalMode::Always => ApprovalDecision::Allow.into(),
251            ApprovalMode::Never => ApprovalAnswer::new(ApprovalDecision::Deny).because(format!(
252                "{} changes state outside this process, and this session is set to refuse that",
253                request.tool_name
254            )),
255            ApprovalMode::Prompt => self.ask(request).await,
256        }
257    }
258}
259
260impl<A: Approver> ModedApprover<A> {
261    async fn ask(&mut self, request: &ApprovalRequest) -> ApprovalAnswer {
262        if let Some(allow) = self.modes.remembered(&request.tool_name) {
263            return if allow {
264                ApprovalDecision::Allow.into()
265            } else {
266                ApprovalAnswer::new(ApprovalDecision::Deny).because(format!(
267                    "{} was refused earlier in this session, and that answer still stands",
268                    request.tool_name
269                ))
270            };
271        }
272
273        // The two "…for this session" answers are collapsed to a plain one
274        // before mentra sees them, and remembered here instead — see the
275        // module docs. The reason survives the collapse, because it is what
276        // the model reads.
277        let answer = self.inner.approve(request).await;
278        match answer.decision {
279            ApprovalDecision::AllowForSession => {
280                self.modes.remember(&request.tool_name, true);
281                ApprovalAnswer {
282                    decision: ApprovalDecision::Allow,
283                    ..answer
284                }
285            }
286            ApprovalDecision::DenyForSession => {
287                self.modes.remember(&request.tool_name, false);
288                ApprovalAnswer {
289                    decision: ApprovalDecision::Deny,
290                    ..answer
291                }
292            }
293            _ => answer,
294        }
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use basis::ToolSideEffectLevel;
302    use serde_json::json;
303    use std::sync::atomic::{AtomicUsize, Ordering};
304
305    /// Counts how often it was consulted, so a test can prove a mode answered
306    /// without asking.
307    struct Counting {
308        asked: Arc<AtomicUsize>,
309        answer: ApprovalDecision,
310    }
311
312    #[async_trait::async_trait]
313    impl Approver for Counting {
314        async fn approve(&mut self, _request: &ApprovalRequest) -> ApprovalAnswer {
315            self.asked.fetch_add(1, Ordering::SeqCst);
316            self.answer.into()
317        }
318    }
319
320    fn request(tool_name: &str) -> ApprovalRequest {
321        ApprovalRequest {
322            request_id: "r1".to_string(),
323            tool_call_id: "c1".to_string(),
324            tool_name: tool_name.to_string(),
325            description: "wants to write".to_string(),
326            input: json!({}),
327            side_effect_level: Some(ToolSideEffectLevel::LocalState),
328        }
329    }
330
331    fn gate(
332        initial: ApprovalMode,
333        answer: ApprovalDecision,
334    ) -> (SessionModes, ModedApprover<Counting>, Arc<AtomicUsize>) {
335        let modes = SessionModes::new(initial);
336        let asked = Arc::new(AtomicUsize::new(0));
337        let approver = ModedApprover::new(
338            modes.clone(),
339            Counting {
340                asked: Arc::clone(&asked),
341                answer,
342            },
343        );
344        (modes, approver, asked)
345    }
346
347    #[test]
348    fn every_offered_mode_maps_back_to_one_lan_can_read() {
349        // An id basis sends but cannot read would be a switch that silently does
350        // nothing.
351        for mode in SessionModes::new(ApprovalMode::Prompt)
352            .state()
353            .available_modes
354        {
355            assert!(
356                mode_for(&mode.id.0).is_some(),
357                "offered {} but cannot read it back",
358                mode.id.0
359            );
360        }
361    }
362
363    #[test]
364    fn the_state_reports_the_current_mode_and_all_three() {
365        let state = SessionModes::new(ApprovalMode::Prompt).state();
366
367        assert_eq!(&*state.current_mode_id.0, PROMPT);
368        assert_eq!(state.available_modes.len(), 3);
369    }
370
371    #[test]
372    fn a_read_only_session_offers_nothing_else() {
373        let modes = SessionModes::new(ApprovalMode::Never);
374        let state = modes.state();
375
376        assert_eq!(state.available_modes.len(), 1);
377        assert_eq!(&*state.current_mode_id.0, NEVER);
378        assert_eq!(
379            modes.set(&SessionModeId::new(ALWAYS)),
380            Err(ModeError::NotOffered),
381            "a client cannot lift a prohibition it was never given"
382        );
383    }
384
385    #[test]
386    fn switching_reports_the_new_mode() {
387        let modes = SessionModes::new(ApprovalMode::Prompt);
388
389        assert_eq!(
390            modes.set(&SessionModeId::new(ALWAYS)),
391            Ok(ApprovalMode::Always)
392        );
393        assert_eq!(modes.current(), ApprovalMode::Always);
394    }
395
396    #[test]
397    fn an_unknown_mode_is_refused() {
398        let modes = SessionModes::new(ApprovalMode::Prompt);
399
400        assert_eq!(
401            modes.set(&SessionModeId::new("architect")),
402            Err(ModeError::Unknown)
403        );
404        assert_eq!(
405            modes.current(),
406            ApprovalMode::Prompt,
407            "a refused switch must leave the session where it was"
408        );
409    }
410
411    #[tokio::test]
412    async fn allow_and_refuse_answer_without_asking() {
413        for (mode, expected) in [
414            (ApprovalMode::Always, ApprovalDecision::Allow),
415            (ApprovalMode::Never, ApprovalDecision::Deny),
416        ] {
417            let (_modes, mut approver, asked) = gate(mode, ApprovalDecision::Allow);
418
419            assert_eq!(approver.approve(&request("shell")).await.decision, expected);
420            assert_eq!(
421                asked.load(Ordering::SeqCst),
422                0,
423                "{mode:?} has nothing to ask about"
424            );
425        }
426    }
427
428    #[tokio::test]
429    async fn a_read_only_session_says_so_when_it_refuses() {
430        // The model reads this as the tool result, and "denied" on its own
431        // would have it try the same write again.
432        let (_modes, mut approver, _asked) = gate(ApprovalMode::Never, ApprovalDecision::Allow);
433
434        assert_eq!(
435            approver.approve(&request("shell")).await.reason.as_deref(),
436            Some(
437                "shell changes state outside this process, \
438                 and this session is set to refuse that"
439            )
440        );
441    }
442
443    #[tokio::test]
444    async fn asking_puts_the_request_to_the_client() {
445        let (_modes, mut approver, asked) = gate(ApprovalMode::Prompt, ApprovalDecision::Allow);
446
447        assert_eq!(
448            approver.approve(&request("shell")).await.decision,
449            ApprovalDecision::Allow
450        );
451        assert_eq!(asked.load(Ordering::SeqCst), 1);
452    }
453
454    #[tokio::test]
455    async fn an_answer_for_the_session_is_not_asked_twice() {
456        let (_modes, mut approver, asked) =
457            gate(ApprovalMode::Prompt, ApprovalDecision::AllowForSession);
458
459        // Collapsed to a plain allow so mentra does not store a rule of its
460        // own — the one basis keeps is the one a mode change can clear.
461        assert_eq!(
462            approver.approve(&request("shell")).await.decision,
463            ApprovalDecision::Allow
464        );
465        assert_eq!(
466            approver.approve(&request("shell")).await.decision,
467            ApprovalDecision::Allow
468        );
469        assert_eq!(asked.load(Ordering::SeqCst), 1);
470
471        // A different tool was never answered for.
472        assert_eq!(
473            approver.approve(&request("files")).await.decision,
474            ApprovalDecision::Allow
475        );
476        assert_eq!(asked.load(Ordering::SeqCst), 2);
477    }
478
479    #[tokio::test]
480    async fn changing_mode_forgets_what_was_allowed_for_the_session() {
481        let (modes, mut approver, _asked) =
482            gate(ApprovalMode::Prompt, ApprovalDecision::AllowForSession);
483
484        approver.approve(&request("shell")).await;
485        modes.set(&SessionModeId::new(NEVER)).expect("switches");
486
487        assert_eq!(
488            approver.approve(&request("shell")).await.decision,
489            ApprovalDecision::Deny,
490            "a stale allow must not survive the mode that replaced it"
491        );
492
493        // And it stays forgotten on the way back, rather than reappearing.
494        modes.set(&SessionModeId::new(PROMPT)).expect("switches");
495        assert_eq!(
496            approver.approve(&request("shell")).await.decision,
497            ApprovalDecision::Allow,
498            "the client is asked again, and answered again"
499        );
500    }
501
502    #[tokio::test]
503    async fn a_refusal_for_the_session_is_also_remembered() {
504        let (_modes, mut approver, asked) =
505            gate(ApprovalMode::Prompt, ApprovalDecision::DenyForSession);
506
507        assert_eq!(
508            approver.approve(&request("shell")).await.decision,
509            ApprovalDecision::Deny
510        );
511
512        let repeated = approver.approve(&request("shell")).await;
513        assert_eq!(repeated.decision, ApprovalDecision::Deny);
514        assert_eq!(
515            repeated.reason.as_deref(),
516            Some("shell was refused earlier in this session, and that answer still stands"),
517            "a remembered refusal still owes the model a reason"
518        );
519        assert_eq!(asked.load(Ordering::SeqCst), 1);
520    }
521}