tirea_extension_permission/
state.rs

1use crate::model::{
2    PermissionRule, PermissionRuleScope, PermissionRuleSource, PermissionRuleset,
3    ToolPermissionBehavior,
4};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use tirea_state::State;
8
9/// Public permission-domain action exposed to tools/plugins.
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11#[serde(tag = "type", rename_all = "snake_case")]
12pub enum PermissionAction {
13    SetDefault {
14        behavior: ToolPermissionBehavior,
15    },
16    SetTool {
17        tool_id: String,
18        behavior: ToolPermissionBehavior,
19    },
20    RemoveTool {
21        tool_id: String,
22    },
23    ClearTools,
24}
25
26/// Action type for the [`PermissionPolicy`] reducer.
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(tag = "type", rename_all = "snake_case")]
29pub enum PermissionPolicyAction {
30    SetDefault {
31        behavior: ToolPermissionBehavior,
32    },
33    SetTool {
34        tool_id: String,
35        behavior: ToolPermissionBehavior,
36        #[serde(default)]
37        scope: PermissionRuleScope,
38        #[serde(default)]
39        source: PermissionRuleSource,
40    },
41    RemoveTool {
42        tool_id: String,
43    },
44    ClearTools,
45    AllowTool {
46        tool_id: String,
47    },
48    DenyTool {
49        tool_id: String,
50    },
51}
52
53/// Persisted permission rules.
54#[derive(Debug, Clone, Default, Serialize, Deserialize, State)]
55#[serde(default)]
56#[tirea(
57    path = "permission_policy",
58    action = "PermissionPolicyAction",
59    scope = "thread"
60)]
61pub struct PermissionPolicy {
62    pub default_behavior: ToolPermissionBehavior,
63    pub rules: HashMap<String, PermissionRule>,
64}
65
66/// Run-scoped permission overrides applied on top of thread-level [`PermissionPolicy`].
67///
68/// Automatically cleaned up by `prepare_run()` when the run ends. Used by skill
69/// activation to grant temporary tool permissions that do not leak across runs.
70#[derive(Debug, Clone, Default, Serialize, Deserialize, State)]
71#[serde(default)]
72#[tirea(
73    path = "permission_overrides",
74    action = "PermissionOverridesAction",
75    scope = "run"
76)]
77pub struct PermissionOverrides {
78    pub rules: HashMap<String, PermissionRule>,
79}
80
81/// Action type for the [`PermissionOverrides`] reducer.
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
83#[serde(tag = "type", rename_all = "snake_case")]
84pub enum PermissionOverridesAction {
85    SetTool {
86        tool_id: String,
87        behavior: ToolPermissionBehavior,
88        #[serde(default)]
89        scope: PermissionRuleScope,
90        #[serde(default)]
91        source: PermissionRuleSource,
92    },
93    RemoveTool {
94        tool_id: String,
95    },
96    Clear,
97}
98
99impl PermissionOverrides {
100    fn upsert_tool_rule(
101        &mut self,
102        tool_id: String,
103        behavior: ToolPermissionBehavior,
104        scope: PermissionRuleScope,
105        source: PermissionRuleSource,
106    ) {
107        let rule = PermissionRule::new_tool(tool_id, behavior)
108            .with_scope(scope)
109            .with_source(source);
110        self.rules.insert(rule.subject.key(), rule);
111    }
112
113    pub(super) fn reduce(&mut self, action: PermissionOverridesAction) {
114        match action {
115            PermissionOverridesAction::SetTool {
116                tool_id,
117                behavior,
118                scope,
119                source,
120            } => self.upsert_tool_rule(tool_id, behavior, scope, source),
121            PermissionOverridesAction::RemoveTool { tool_id } => {
122                self.rules.remove(
123                    &PermissionRule::new_tool(tool_id, ToolPermissionBehavior::Ask)
124                        .subject
125                        .key(),
126                );
127            }
128            PermissionOverridesAction::Clear => self.rules.clear(),
129        }
130    }
131}
132
133impl PermissionPolicy {
134    fn upsert_tool_rule(
135        &mut self,
136        tool_id: String,
137        behavior: ToolPermissionBehavior,
138        scope: PermissionRuleScope,
139        source: PermissionRuleSource,
140    ) {
141        let rule = PermissionRule::new_tool(tool_id, behavior)
142            .with_scope(scope)
143            .with_source(source);
144        self.rules.insert(rule.subject.key(), rule);
145    }
146
147    pub(super) fn reduce(&mut self, action: PermissionPolicyAction) {
148        match action {
149            PermissionPolicyAction::SetDefault { behavior } => self.default_behavior = behavior,
150            PermissionPolicyAction::SetTool {
151                tool_id,
152                behavior,
153                scope,
154                source,
155            } => self.upsert_tool_rule(tool_id, behavior, scope, source),
156            PermissionPolicyAction::RemoveTool { tool_id } => {
157                self.rules.remove(
158                    &PermissionRule::new_tool(tool_id, ToolPermissionBehavior::Ask)
159                        .subject
160                        .key(),
161                );
162            }
163            PermissionPolicyAction::ClearTools => self.rules.clear(),
164            PermissionPolicyAction::AllowTool { tool_id } => self.upsert_tool_rule(
165                tool_id,
166                ToolPermissionBehavior::Allow,
167                PermissionRuleScope::Thread,
168                PermissionRuleSource::Runtime,
169            ),
170            PermissionPolicyAction::DenyTool { tool_id } => self.upsert_tool_rule(
171                tool_id,
172                ToolPermissionBehavior::Deny,
173                PermissionRuleScope::Thread,
174                PermissionRuleSource::Runtime,
175            ),
176        }
177    }
178}
179
180#[derive(Debug, Clone, Default, Serialize, Deserialize)]
181#[serde(default)]
182struct LegacyPermissionOverrides {
183    pub default_behavior: ToolPermissionBehavior,
184    pub tools: HashMap<String, ToolPermissionBehavior>,
185}
186
187#[derive(Debug, Clone, Default, Serialize, Deserialize)]
188#[serde(default)]
189struct LegacyPermissionPolicy {
190    pub default_behavior: ToolPermissionBehavior,
191    pub allowed_tools: Vec<String>,
192    pub denied_tools: Vec<String>,
193}
194
195/// Route a [`PermissionAction`] to the canonical [`PermissionPolicy`] state.
196pub fn permission_state_action(
197    action: PermissionAction,
198) -> tirea_contract::runtime::state::AnyStateAction {
199    use tirea_contract::runtime::state::AnyStateAction;
200    let policy_action = match action {
201        PermissionAction::SetDefault { behavior } => {
202            PermissionPolicyAction::SetDefault { behavior }
203        }
204        PermissionAction::SetTool { tool_id, behavior } => PermissionPolicyAction::SetTool {
205            tool_id,
206            behavior,
207            scope: PermissionRuleScope::Thread,
208            source: PermissionRuleSource::Runtime,
209        },
210        PermissionAction::RemoveTool { tool_id } => PermissionPolicyAction::RemoveTool { tool_id },
211        PermissionAction::ClearTools => PermissionPolicyAction::ClearTools,
212    };
213    AnyStateAction::new::<PermissionPolicy>(policy_action)
214}
215
216/// Route a [`PermissionAction`] to the run-scoped [`PermissionOverrides`] state.
217///
218/// Use this instead of [`permission_state_action`] when the permission change
219/// should be temporary and automatically cleaned up at the end of the current run
220/// (e.g., skill-granted tool permissions).
221pub fn permission_override_action(
222    action: PermissionAction,
223) -> tirea_contract::runtime::state::AnyStateAction {
224    use tirea_contract::runtime::state::AnyStateAction;
225    let overrides_action = match action {
226        PermissionAction::SetTool { tool_id, behavior } => PermissionOverridesAction::SetTool {
227            tool_id,
228            behavior,
229            scope: PermissionRuleScope::Thread,
230            source: PermissionRuleSource::Skill,
231        },
232        PermissionAction::RemoveTool { tool_id } => {
233            PermissionOverridesAction::RemoveTool { tool_id }
234        }
235        PermissionAction::ClearTools => PermissionOverridesAction::Clear,
236        // SetDefault has no run-scoped equivalent; route to thread-level policy.
237        PermissionAction::SetDefault { behavior } => {
238            return permission_state_action(PermissionAction::SetDefault { behavior });
239        }
240    };
241    AnyStateAction::new::<PermissionOverrides>(overrides_action)
242}
243
244/// Load resolved permission rules from a runtime snapshot.
245///
246/// Merges three layers with descending priority:
247/// 1. Run-scoped [`PermissionOverrides`] (highest — temporary skill grants)
248/// 2. Thread-level [`PermissionPolicy`] (base rules)
249/// 3. Legacy `permissions` snapshot (lowest — backward compat)
250#[must_use]
251pub fn permission_rules_from_snapshot(snapshot: &serde_json::Value) -> PermissionRuleset {
252    let mut ruleset = PermissionRuleset::default();
253    let mut default_from_new_state = false;
254
255    if let Some(policy_value) = snapshot.get(PermissionPolicy::PATH) {
256        let prefers_legacy_shape = policy_value.get("allowed_tools").is_some()
257            || policy_value.get("denied_tools").is_some();
258        if prefers_legacy_shape {
259            if let Ok(legacy_policy) =
260                serde_json::from_value::<LegacyPermissionPolicy>(policy_value.clone())
261            {
262                default_from_new_state = true;
263                ruleset.default_behavior = legacy_policy.default_behavior;
264                for tool_id in legacy_policy.allowed_tools {
265                    let rule = PermissionRule::new_tool(tool_id, ToolPermissionBehavior::Allow)
266                        .with_source(PermissionRuleSource::Runtime);
267                    ruleset.rules.entry(rule.subject.key()).or_insert(rule);
268                }
269                for tool_id in legacy_policy.denied_tools {
270                    let rule = PermissionRule::new_tool(tool_id, ToolPermissionBehavior::Deny)
271                        .with_source(PermissionRuleSource::Runtime);
272                    ruleset.rules.insert(rule.subject.key(), rule);
273                }
274            }
275        } else if let Ok(policy) = PermissionPolicy::from_value(policy_value) {
276            default_from_new_state = true;
277            ruleset.default_behavior = policy.default_behavior;
278            ruleset.rules.extend(policy.rules);
279        }
280    }
281
282    if let Some(legacy_value) = snapshot.get("permissions") {
283        if let Ok(legacy) =
284            serde_json::from_value::<LegacyPermissionOverrides>(legacy_value.clone())
285        {
286            if !default_from_new_state {
287                ruleset.default_behavior = legacy.default_behavior;
288            }
289            for (tool_id, behavior) in legacy.tools {
290                let rule = PermissionRule::new_tool(tool_id, behavior)
291                    .with_source(PermissionRuleSource::Runtime);
292                ruleset.rules.entry(rule.subject.key()).or_insert(rule);
293            }
294        }
295    }
296
297    // Apply run-scoped overrides last — they take highest priority.
298    if let Some(overrides_value) = snapshot.get(PermissionOverrides::PATH) {
299        if let Ok(overrides) = PermissionOverrides::from_value(overrides_value) {
300            for (key, rule) in overrides.rules {
301                ruleset.rules.insert(key, rule);
302            }
303        }
304    }
305
306    ruleset
307}