Skip to main content

batuta/agent/
permission.rs

1//! Permission modes for `apr code` (Claude-Code parity).
2//!
3//! PMAT-CODE-PERMISSIONS-001: Claude Code cycles through four
4//! permission modes via `Shift+Tab` (and advertises them in the
5//! status line):
6//!
7//! | Mode                | Reads | Writes/Edits | Shell   |
8//! |---------------------|-------|--------------|---------|
9//! | `default`           | ✓     | ask          | ask     |
10//! | `plan`              | ✓     | blocked      | blocked |
11//! | `acceptEdits`       | ✓     | auto-allow   | ask     |
12//! | `bypassPermissions` | ✓     | auto-allow   | auto-allow |
13//!
14//! This module ships the enum + per-mode policy so call sites can
15//! query `policy.verdict(capability)` instead of hard-coding the
16//! matrix at each prompt site. Runtime wiring into the REPL prompt
17//! loop (the actual Shift+Tab / status-line UX) is follow-up
18//! PMAT-CODE-PERMISSIONS-RUNTIME-001 (P2).
19//!
20//! # Example
21//!
22//! ```rust,ignore
23//! use aprender_orchestrate::agent::permission::{PermissionMode, PermissionVerdict};
24//! use aprender_orchestrate::agent::capability::Capability;
25//!
26//! let mode = PermissionMode::Plan;
27//! assert_eq!(
28//!     mode.verdict(&Capability::FileRead { allowed_paths: vec!["*".into()] }),
29//!     PermissionVerdict::Allow
30//! );
31//! assert_eq!(
32//!     mode.verdict(&Capability::FileWrite { allowed_paths: vec!["*".into()] }),
33//!     PermissionVerdict::Block
34//! );
35//! ```
36
37use serde::{Deserialize, Serialize};
38
39use super::capability::Capability;
40
41/// One of the four Claude-Code permission modes.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub enum PermissionMode {
45    /// Prompt per action (read auto-allowed). Claude Code's launch
46    /// default.
47    #[default]
48    Default,
49    /// Read-only exploration. Writes, edits, and shell invocations
50    /// are blocked outright.
51    Plan,
52    /// Writes and edits auto-allowed; shell still prompts.
53    AcceptEdits,
54    /// No prompts — every capability auto-allowed. The "YOLO" mode.
55    BypassPermissions,
56}
57
58/// What the policy says about a single capability request.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum PermissionVerdict {
61    /// Run without prompting.
62    Allow,
63    /// Pause and ask the user (the REPL-runtime layer handles UX).
64    Ask,
65    /// Refuse outright; surface a blocked-by-policy error.
66    Block,
67}
68
69impl PermissionMode {
70    /// Parse from a CLI / TOML string. Accepts both `camelCase`
71    /// (canonical, matches Claude Code) and `kebab-case` spellings.
72    pub fn parse(s: &str) -> Option<Self> {
73        match s.trim() {
74            "default" => Some(Self::Default),
75            "plan" => Some(Self::Plan),
76            "acceptEdits" | "accept-edits" | "accept_edits" => Some(Self::AcceptEdits),
77            "bypassPermissions" | "bypass-permissions" | "bypass_permissions" => {
78                Some(Self::BypassPermissions)
79            }
80            _ => None,
81        }
82    }
83
84    /// Canonical camelCase identifier (matches Claude Code + our
85    /// serde rename).
86    pub fn as_str(self) -> &'static str {
87        match self {
88            Self::Default => "default",
89            Self::Plan => "plan",
90            Self::AcceptEdits => "acceptEdits",
91            Self::BypassPermissions => "bypassPermissions",
92        }
93    }
94
95    /// Next mode in the Shift+Tab cycle (wraps around).
96    ///
97    /// Order mirrors Claude Code's cycle: default → plan →
98    /// acceptEdits → bypassPermissions → default.
99    pub fn next(self) -> Self {
100        match self {
101            Self::Default => Self::Plan,
102            Self::Plan => Self::AcceptEdits,
103            Self::AcceptEdits => Self::BypassPermissions,
104            Self::BypassPermissions => Self::Default,
105        }
106    }
107
108    /// Verdict on a single capability request.
109    pub fn verdict(self, capability: &Capability) -> PermissionVerdict {
110        match self {
111            Self::BypassPermissions => PermissionVerdict::Allow,
112            Self::Default => match capability {
113                Capability::FileRead { .. } | Capability::Memory | Capability::Rag => {
114                    PermissionVerdict::Allow
115                }
116                _ => PermissionVerdict::Ask,
117            },
118            Self::Plan => match capability {
119                Capability::FileRead { .. } | Capability::Memory | Capability::Rag => {
120                    PermissionVerdict::Allow
121                }
122                _ => PermissionVerdict::Block,
123            },
124            Self::AcceptEdits => match capability {
125                Capability::FileRead { .. }
126                | Capability::FileWrite { .. }
127                | Capability::Memory
128                | Capability::Rag => PermissionVerdict::Allow,
129                _ => PermissionVerdict::Ask,
130            },
131        }
132    }
133
134    /// True when a capability request would run without user
135    /// interaction under this mode (helpful for non-interactive
136    /// `apr code -p` batch jobs).
137    pub fn would_run_unattended(self, capability: &Capability) -> bool {
138        matches!(self.verdict(capability), PermissionVerdict::Allow)
139    }
140}
141
142impl std::fmt::Display for PermissionMode {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        f.write_str(self.as_str())
145    }
146}
147
148#[cfg(test)]
149mod tests;