Skip to main content

a3s_code_core/permissions/
policy.rs

1use serde::{Deserialize, Serialize};
2
3use super::{MatchingRules, PermissionChecker, PermissionDecision, PermissionRule};
4use crate::queue::SessionLane;
5
6fn yolo_lane_rule(lane: SessionLane) -> String {
7    match lane {
8        SessionLane::Control => "lane:control".to_string(),
9        SessionLane::Query => "lane:query".to_string(),
10        SessionLane::Execute => "lane:execute".to_string(),
11        SessionLane::Generate => "lane:generate".to_string(),
12    }
13}
14
15/// Permission policy configuration
16///
17/// Evaluation order:
18/// 1. Deny rules - any match results in denial
19/// 2. Allow rules - any match results in auto-approval
20/// 3. Ask rules - any match requires user confirmation
21/// 4. Default - falls back to default_decision
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct PermissionPolicy {
24    /// Rules that always deny (checked first)
25    #[serde(default)]
26    pub deny: Vec<PermissionRule>,
27
28    /// Rules that auto-approve without confirmation
29    #[serde(default)]
30    pub allow: Vec<PermissionRule>,
31
32    /// Rules that always require confirmation
33    #[serde(default)]
34    pub ask: Vec<PermissionRule>,
35
36    /// Default decision when no rules match
37    #[serde(default = "default_decision")]
38    pub default_decision: PermissionDecision,
39
40    /// Whether the permission system is enabled
41    #[serde(default = "default_enabled")]
42    pub enabled: bool,
43}
44
45fn default_decision() -> PermissionDecision {
46    PermissionDecision::Ask
47}
48
49fn default_enabled() -> bool {
50    true
51}
52
53impl Default for PermissionPolicy {
54    fn default() -> Self {
55        Self {
56            deny: Vec::new(),
57            allow: Vec::new(),
58            ask: Vec::new(),
59            default_decision: PermissionDecision::Ask,
60            enabled: true,
61        }
62    }
63}
64
65impl PermissionPolicy {
66    /// Create a new permission policy
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    /// Create a strict policy that asks for everything
72    pub fn strict() -> Self {
73        Self {
74            deny: Vec::new(),
75            allow: Vec::new(),
76            ask: Vec::new(),
77            default_decision: PermissionDecision::Ask,
78            enabled: true,
79        }
80    }
81
82    /// Add a deny rule
83    pub fn deny(mut self, rule: &str) -> Self {
84        self.deny.push(PermissionRule::new(rule));
85        self
86    }
87
88    /// Add an allow rule
89    pub fn allow(mut self, rule: &str) -> Self {
90        self.allow.push(PermissionRule::new(rule));
91        self
92    }
93
94    /// Record YOLO lanes as Allow. Deny rules still win. The lane of a tool
95    /// comes from [`SessionLane::from_tool_name`].
96    pub fn allow_yolo_lanes(mut self, lanes: impl IntoIterator<Item = SessionLane>) -> Self {
97        for lane in lanes {
98            self.allow.push(PermissionRule::new(&yolo_lane_rule(lane)));
99        }
100        self
101    }
102
103    /// Add an ask rule
104    pub fn ask(mut self, rule: &str) -> Self {
105        self.ask.push(PermissionRule::new(rule));
106        self
107    }
108
109    /// Add multiple deny rules
110    pub fn deny_all(mut self, rules: &[&str]) -> Self {
111        for rule in rules {
112            self.deny.push(PermissionRule::new(rule));
113        }
114        self
115    }
116
117    /// Add multiple allow rules
118    pub fn allow_all(mut self, rules: &[&str]) -> Self {
119        for rule in rules {
120            self.allow.push(PermissionRule::new(rule));
121        }
122        self
123    }
124
125    /// Add multiple ask rules
126    pub fn ask_all(mut self, rules: &[&str]) -> Self {
127        for rule in rules {
128            self.ask.push(PermissionRule::new(rule));
129        }
130        self
131    }
132
133    /// Check permission for a tool invocation
134    ///
135    /// Returns the permission decision based on rule evaluation order:
136    /// 1. Deny rules (any match = Deny)
137    /// 2. Allow rules (any match = Allow)
138    /// 3. Ask rules (any match = Ask)
139    /// 4. Default decision
140    pub fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
141        if !self.enabled {
142            return PermissionDecision::Allow;
143        }
144
145        // 1. Check deny rules first
146        for rule in &self.deny {
147            if rule.matches(tool_name, args) {
148                return PermissionDecision::Deny;
149            }
150        }
151
152        // 2. Check allow rules. A YOLO lane rule is Allow for every tool in
153        // that lane, including names `from_tool_name` maps by default.
154        let lane_rule = yolo_lane_rule(SessionLane::from_tool_name(tool_name));
155        for rule in &self.allow {
156            if rule.rule == lane_rule || rule.matches(tool_name, args) {
157                return PermissionDecision::Allow;
158            }
159        }
160
161        // 3. Check ask rules
162        for rule in &self.ask {
163            if rule.matches(tool_name, args) {
164                return PermissionDecision::Ask;
165            }
166        }
167
168        // 4. Fall back to default
169        self.default_decision
170    }
171
172    /// Check if a tool invocation is allowed (Allow or not Deny)
173    pub fn is_allowed(&self, tool_name: &str, args: &serde_json::Value) -> bool {
174        matches!(self.check(tool_name, args), PermissionDecision::Allow)
175    }
176
177    /// Check if a tool invocation is denied
178    pub fn is_denied(&self, tool_name: &str, args: &serde_json::Value) -> bool {
179        matches!(self.check(tool_name, args), PermissionDecision::Deny)
180    }
181
182    /// Check if a tool invocation requires confirmation
183    pub fn requires_confirmation(&self, tool_name: &str, args: &serde_json::Value) -> bool {
184        matches!(self.check(tool_name, args), PermissionDecision::Ask)
185    }
186
187    /// Get matching rules for debugging/logging
188    pub fn get_matching_rules(&self, tool_name: &str, args: &serde_json::Value) -> MatchingRules {
189        let mut result = MatchingRules::default();
190
191        for rule in &self.deny {
192            if rule.matches(tool_name, args) {
193                result.deny.push(rule.rule.clone());
194            }
195        }
196
197        for rule in &self.allow {
198            if rule.matches(tool_name, args) {
199                result.allow.push(rule.rule.clone());
200            }
201        }
202
203        for rule in &self.ask {
204            if rule.matches(tool_name, args) {
205                result.ask.push(rule.rule.clone());
206            }
207        }
208
209        result
210    }
211
212    /// Whether this policy explicitly declares that a tool may be considered
213    /// through an Allow or Ask rule.
214    ///
215    /// This ignores argument patterns and does not authorize execution. It is
216    /// used when composing parent and delegated-worker visibility: an explicit
217    /// worker capability can override a parent host's ordinary model hiding,
218    /// while execution-time checks from both scopes remain authoritative.
219    pub fn declares_tool_access(&self, tool_name: &str) -> bool {
220        self.allow
221            .iter()
222            .chain(&self.ask)
223            .any(|rule| rule.matches_tool(tool_name))
224    }
225}
226
227impl PermissionChecker for PermissionPolicy {
228    fn expose_to_model(&self, tool_name: &str) -> bool {
229        if !self.enabled {
230            return true;
231        }
232
233        // A deny that covers every argument makes the entire capability
234        // unavailable. Argument-scoped denies keep the tool visible so an
235        // allowed invocation can still be formed and checked at execution.
236        if self
237            .deny
238            .iter()
239            .any(|rule| rule.matches_tool(tool_name) && rule.matches_all_args())
240        {
241            return false;
242        }
243
244        if self.default_decision != PermissionDecision::Deny {
245            return true;
246        }
247
248        // Under a deny-by-default worker policy, expose only tools for which
249        // at least one Allow or Ask rule can match. Argument patterns are
250        // intentionally ignored here; execution-time checks remain
251        // authoritative for the actual arguments.
252        self.declares_tool_access(tool_name)
253    }
254
255    fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
256        self.check(tool_name, args)
257    }
258}