Skip to main content

fxrs_core/
permission.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::ToolEffect;
6
7#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
8#[serde(rename_all = "lowercase")]
9pub enum PermissionMode {
10    Ask,
11    #[default]
12    Auto,
13    Yolo,
14}
15
16#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
17#[serde(rename_all = "lowercase")]
18pub enum PermissionAction {
19    Allow,
20    Ask,
21    Deny,
22}
23
24#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
25pub struct PermissionRule {
26    pub permission: String,
27    pub pattern: String,
28    pub action: PermissionAction,
29}
30
31#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct PermissionRequest {
33    pub permission: String,
34    /// Human-facing and configured-rule matching target.
35    pub target: String,
36    /// Exact session-grant identity. Defaults to `target`, but adapters may
37    /// bind hidden execution context such as cwd or shell profile.
38    pub grant_target: String,
39    pub effect: ToolEffect,
40}
41
42impl PermissionRequest {
43    pub fn new(
44        permission: impl Into<String>,
45        target: impl Into<String>,
46        effect: ToolEffect,
47    ) -> Self {
48        let target = target.into();
49        Self {
50            permission: permission.into(),
51            grant_target: target.clone(),
52            target,
53            effect,
54        }
55    }
56
57    pub fn with_grant_target(mut self, target: impl Into<String>) -> Self {
58        self.grant_target = target.into();
59        self
60    }
61}
62
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub enum PermissionDecision {
65    Allow,
66    Ask,
67    AutoReview,
68    Deny,
69}
70
71/// Pure permission policy shared by interactive, noninteractive, and ACP hosts.
72#[derive(Clone, Debug, Default)]
73pub struct PermissionEngine {
74    mode: PermissionMode,
75    configured_rules: Vec<PermissionRule>,
76    session_grants: Vec<(String, String)>,
77}
78
79impl PermissionEngine {
80    pub fn new(mode: PermissionMode, configured_rules: Vec<PermissionRule>) -> Self {
81        Self {
82            mode,
83            configured_rules,
84            session_grants: Vec::new(),
85        }
86    }
87
88    pub fn mode(&self) -> PermissionMode {
89        self.mode
90    }
91
92    /// Changes the session baseline without discarding configured rules or
93    /// approvals already granted for this session.
94    pub fn set_mode(&mut self, mode: PermissionMode) {
95        self.mode = mode;
96    }
97
98    pub fn rules(&self) -> &[PermissionRule] {
99        &self.configured_rules
100    }
101
102    pub fn grants(&self) -> &[(String, String)] {
103        &self.session_grants
104    }
105
106    pub fn grant_for_session(&mut self, permission: &str, target: &str) {
107        let entry = (permission.to_owned(), target.to_owned());
108        if !self.session_grants.contains(&entry) {
109            self.session_grants.push(entry);
110        }
111    }
112
113    pub fn grant_request_for_session(&mut self, request: &PermissionRequest) {
114        self.grant_for_session(&request.permission, &request.grant_target);
115    }
116
117    /// Resolves a request using the original precedence: yolo, configured
118    /// denies, exact session grants, configured allow/ask, then mode baseline.
119    pub fn decide(&self, request: &PermissionRequest) -> PermissionDecision {
120        if self.mode == PermissionMode::Yolo {
121            return PermissionDecision::Allow;
122        }
123
124        let mut configured = None;
125        for rule in self
126            .configured_rules
127            .iter()
128            .filter(|rule| rule.permission == request.permission || rule.permission == "*")
129            .filter(|rule| pattern_matches(&rule.pattern, &request.target))
130        {
131            configured = Some(match rule.action {
132                PermissionAction::Allow => PermissionDecision::Allow,
133                PermissionAction::Ask => PermissionDecision::Ask,
134                PermissionAction::Deny => PermissionDecision::Deny,
135            });
136        }
137
138        if configured == Some(PermissionDecision::Deny) {
139            return PermissionDecision::Deny;
140        }
141
142        if self.session_grants.iter().any(|(permission, target)| {
143            permission == &request.permission && target == &request.grant_target
144        }) {
145            return PermissionDecision::Allow;
146        }
147
148        configured.unwrap_or(match self.mode {
149            PermissionMode::Ask => PermissionDecision::Ask,
150            PermissionMode::Auto => automatic_baseline(request.effect),
151            PermissionMode::Yolo => PermissionDecision::Allow,
152        })
153    }
154}
155
156fn automatic_baseline(effect: ToolEffect) -> PermissionDecision {
157    match effect {
158        ToolEffect::Read => PermissionDecision::Allow,
159        ToolEffect::Write
160        | ToolEffect::Process
161        | ToolEffect::Network
162        | ToolEffect::UserInteraction
163        | ToolEffect::Delegation => PermissionDecision::AutoReview,
164    }
165}
166
167fn pattern_matches(pattern: &str, target: &str) -> bool {
168    if pattern == "*" || pattern == target {
169        return true;
170    }
171    let Some(prefix) = pattern.strip_suffix("/**") else {
172        return false;
173    };
174    let prefix = Path::new(prefix);
175    PathBuf::from(target).starts_with(prefix)
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn request(effect: ToolEffect) -> PermissionRequest {
183        PermissionRequest::new("write_file", "/repo/src/lib.rs", effect)
184    }
185
186    #[test]
187    fn configured_deny_wins_over_session_grant() {
188        let mut engine = PermissionEngine::new(
189            PermissionMode::Auto,
190            vec![PermissionRule {
191                permission: "write_file".into(),
192                pattern: "/repo/**".into(),
193                action: PermissionAction::Deny,
194            }],
195        );
196        engine.grant_for_session("write_file", "/repo/src/lib.rs");
197
198        assert_eq!(
199            engine.decide(&request(ToolEffect::Write)),
200            PermissionDecision::Deny
201        );
202    }
203
204    #[test]
205    fn auto_allows_reads_and_reviews_mutations() {
206        let engine = PermissionEngine::new(PermissionMode::Auto, Vec::new());
207        assert_eq!(
208            engine.decide(&request(ToolEffect::Read)),
209            PermissionDecision::Allow
210        );
211        assert_eq!(
212            engine.decide(&request(ToolEffect::Write)),
213            PermissionDecision::AutoReview
214        );
215    }
216
217    #[test]
218    fn later_matching_configured_rule_wins_within_one_layer() {
219        let engine = PermissionEngine::new(
220            PermissionMode::Auto,
221            vec![
222                PermissionRule {
223                    permission: "write_file".into(),
224                    pattern: "/repo/**".into(),
225                    action: PermissionAction::Deny,
226                },
227                PermissionRule {
228                    permission: "write_file".into(),
229                    pattern: "/repo/src/lib.rs".into(),
230                    action: PermissionAction::Allow,
231                },
232            ],
233        );
234
235        assert_eq!(
236            engine.decide(&request(ToolEffect::Write)),
237            PermissionDecision::Allow
238        );
239    }
240
241    #[test]
242    fn configured_ask_stays_interactive_in_auto_mode() {
243        let engine = PermissionEngine::new(
244            PermissionMode::Auto,
245            vec![PermissionRule {
246                permission: "write_file".into(),
247                pattern: "/repo/**".into(),
248                action: PermissionAction::Ask,
249            }],
250        );
251        assert_eq!(
252            engine.decide(&request(ToolEffect::Write)),
253            PermissionDecision::Ask
254        );
255    }
256}