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;