Skip to main content

edda_core/
tool_tier.rs

1//! Tool tier governance policy — T0..T4 classification and query.
2//!
3//! Defines tool risk tiers and approval requirements. The runtime source of
4//! truth is `tool_tiers.yaml` in the `.edda/` directory; changes are also
5//! recorded as decision events in the ledger for audit.
6
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9use std::fmt;
10use std::path::Path;
11use std::str::FromStr;
12
13// ── ToolTier enum ──
14
15/// Risk tier for a tool (T0 = safest, T4 = forbidden).
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
17pub enum ToolTier {
18    T0,
19    T1,
20    T2,
21    T3,
22    T4,
23}
24
25impl fmt::Display for ToolTier {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        match self {
28            ToolTier::T0 => write!(f, "T0"),
29            ToolTier::T1 => write!(f, "T1"),
30            ToolTier::T2 => write!(f, "T2"),
31            ToolTier::T3 => write!(f, "T3"),
32            ToolTier::T4 => write!(f, "T4"),
33        }
34    }
35}
36
37impl FromStr for ToolTier {
38    type Err = anyhow::Error;
39    fn from_str(s: &str) -> Result<Self, Self::Err> {
40        match s.to_uppercase().as_str() {
41            "T0" => Ok(ToolTier::T0),
42            "T1" => Ok(ToolTier::T1),
43            "T2" => Ok(ToolTier::T2),
44            "T3" => Ok(ToolTier::T3),
45            "T4" => Ok(ToolTier::T4),
46            other => anyhow::bail!("invalid tool tier: '{other}' (expected T0..T4)"),
47        }
48    }
49}
50
51// ── Approval requirement ──
52
53/// What approval is needed before executing a tool at this tier.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum ApprovalRequirement {
57    /// No approval needed.
58    None,
59    /// Execute immediately but log for post-review.
60    Lazy,
61    /// Must get explicit approval before execution.
62    Required,
63    /// Cannot execute under any circumstance.
64    Blocked,
65}
66
67impl fmt::Display for ApprovalRequirement {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        match self {
70            ApprovalRequirement::None => write!(f, "none"),
71            ApprovalRequirement::Lazy => write!(f, "lazy"),
72            ApprovalRequirement::Required => write!(f, "required"),
73            ApprovalRequirement::Blocked => write!(f, "blocked"),
74        }
75    }
76}
77
78// ── Tier definition ──
79
80/// Metadata for a single tier level (stored in YAML).
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct TierDef {
83    pub description: String,
84    pub approval: ApprovalRequirement,
85}
86
87// ── Config (loaded from tool_tiers.yaml) ──
88
89/// Full tool-tier policy loaded from `.edda/tool_tiers.yaml`.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ToolTierConfig {
92    pub version: u32,
93    pub default_tier: ToolTier,
94    pub tiers: BTreeMap<ToolTier, TierDef>,
95    #[serde(default)]
96    pub tools: BTreeMap<String, ToolTier>,
97}
98
99// ── Query result ──
100
101/// Result of resolving a tool's tier — returned by the API.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct ToolTierResult {
104    pub tool: String,
105    pub tier: ToolTier,
106    pub approval: ApprovalRequirement,
107    pub description: String,
108}
109
110// ── Entry for list output ──
111
112/// A single tool -> tier entry for list display.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct ToolTierEntry {
115    pub tool: String,
116    pub tier: ToolTier,
117    pub approval: ApprovalRequirement,
118}
119
120// ── Default config ──
121
122/// Build a sensible default `ToolTierConfig` (no tools mapped yet).
123pub fn default_tool_tier_config() -> ToolTierConfig {
124    let mut tiers = BTreeMap::new();
125    tiers.insert(
126        ToolTier::T0,
127        TierDef {
128            description: "Safe \u{2014} read-only, no side effects".to_string(),
129            approval: ApprovalRequirement::None,
130        },
131    );
132    tiers.insert(
133        ToolTier::T1,
134        TierDef {
135            description: "Standard \u{2014} normal operations".to_string(),
136            approval: ApprovalRequirement::None,
137        },
138    );
139    tiers.insert(
140        ToolTier::T2,
141        TierDef {
142            description: "Elevated \u{2014} significant changes".to_string(),
143            approval: ApprovalRequirement::Lazy,
144        },
145    );
146    tiers.insert(
147        ToolTier::T3,
148        TierDef {
149            description: "Dangerous \u{2014} destructive or irreversible".to_string(),
150            approval: ApprovalRequirement::Required,
151        },
152    );
153    tiers.insert(
154        ToolTier::T4,
155        TierDef {
156            description: "Forbidden \u{2014} never allowed".to_string(),
157            approval: ApprovalRequirement::Blocked,
158        },
159    );
160
161    ToolTierConfig {
162        version: 1,
163        default_tier: ToolTier::T1,
164        tiers,
165        tools: BTreeMap::new(),
166    }
167}
168
169// ── Query logic ──
170
171/// Resolve a tool's tier from the config, falling back to `default_tier`.
172pub fn resolve_tool_tier(config: &ToolTierConfig, tool_name: &str) -> ToolTierResult {
173    let tier = config
174        .tools
175        .get(tool_name)
176        .copied()
177        .unwrap_or(config.default_tier);
178
179    let tier_def = config.tiers.get(&tier);
180    let (approval, description) = match tier_def {
181        Some(def) => (def.approval, def.description.clone()),
182        // Fallback for missing tier definition (shouldn't happen with defaults)
183        None => (approval_for_tier(tier), format!("Tier {tier}")),
184    };
185
186    ToolTierResult {
187        tool: tool_name.to_string(),
188        tier,
189        approval,
190        description,
191    }
192}
193
194/// Derive the canonical approval requirement from a tier level.
195pub fn approval_for_tier(tier: ToolTier) -> ApprovalRequirement {
196    match tier {
197        ToolTier::T0 | ToolTier::T1 => ApprovalRequirement::None,
198        ToolTier::T2 => ApprovalRequirement::Lazy,
199        ToolTier::T3 => ApprovalRequirement::Required,
200        ToolTier::T4 => ApprovalRequirement::Blocked,
201    }
202}
203
204// ── YAML file I/O ──
205
206const TOOL_TIERS_FILE: &str = "tool_tiers.yaml";
207
208/// Load `tool_tiers.yaml` from the `.edda/` directory.
209/// Returns a default config if the file doesn't exist.
210pub fn load_tool_tiers_from_dir(edda_dir: &Path) -> anyhow::Result<ToolTierConfig> {
211    let path = edda_dir.join(TOOL_TIERS_FILE);
212    if !path.exists() {
213        return Ok(default_tool_tier_config());
214    }
215    let content = std::fs::read(&path)?;
216    let config: ToolTierConfig = serde_yaml::from_slice(&content)?;
217    Ok(config)
218}
219
220/// Save `tool_tiers.yaml` to the `.edda/` directory.
221pub fn save_tool_tiers_to_dir(edda_dir: &Path, config: &ToolTierConfig) -> anyhow::Result<()> {
222    let path = edda_dir.join(TOOL_TIERS_FILE);
223    let yaml = serde_yaml::to_string(config)?;
224    std::fs::write(&path, yaml.as_bytes())?;
225    Ok(())
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn resolve_known_tool() {
234        let mut config = default_tool_tier_config();
235        config.tools.insert("bash".to_string(), ToolTier::T1);
236        config.tools.insert("rm".to_string(), ToolTier::T3);
237
238        let result = resolve_tool_tier(&config, "bash");
239        assert_eq!(result.tier, ToolTier::T1);
240        assert_eq!(result.approval, ApprovalRequirement::None);
241
242        let result = resolve_tool_tier(&config, "rm");
243        assert_eq!(result.tier, ToolTier::T3);
244        assert_eq!(result.approval, ApprovalRequirement::Required);
245    }
246
247    #[test]
248    fn resolve_unknown_tool_uses_default() {
249        let config = default_tool_tier_config();
250        let result = resolve_tool_tier(&config, "unknown_tool");
251        assert_eq!(result.tier, ToolTier::T1); // default_tier
252        assert_eq!(result.approval, ApprovalRequirement::None);
253        assert_eq!(result.tool, "unknown_tool");
254    }
255
256    #[test]
257    fn all_tiers_have_correct_approval() {
258        assert_eq!(approval_for_tier(ToolTier::T0), ApprovalRequirement::None);
259        assert_eq!(approval_for_tier(ToolTier::T1), ApprovalRequirement::None);
260        assert_eq!(approval_for_tier(ToolTier::T2), ApprovalRequirement::Lazy);
261        assert_eq!(
262            approval_for_tier(ToolTier::T3),
263            ApprovalRequirement::Required
264        );
265        assert_eq!(
266            approval_for_tier(ToolTier::T4),
267            ApprovalRequirement::Blocked
268        );
269    }
270
271    #[test]
272    fn serde_roundtrip() {
273        let mut config = default_tool_tier_config();
274        config.tools.insert("bash".to_string(), ToolTier::T1);
275        config.tools.insert("Write".to_string(), ToolTier::T2);
276
277        let yaml = serde_yaml::to_string(&config).unwrap();
278        let parsed: ToolTierConfig = serde_yaml::from_str(&yaml).unwrap();
279
280        assert_eq!(parsed.version, 1);
281        assert_eq!(parsed.default_tier, ToolTier::T1);
282        assert_eq!(parsed.tools.get("bash"), Some(&ToolTier::T1));
283        assert_eq!(parsed.tools.get("Write"), Some(&ToolTier::T2));
284        assert_eq!(parsed.tiers.len(), 5);
285    }
286
287    #[test]
288    fn load_missing_file_returns_default() {
289        let tmp = std::env::temp_dir().join(format!(
290            "edda_tool_tier_missing_test_{}",
291            std::process::id()
292        ));
293        let _ = std::fs::create_dir_all(&tmp);
294        let config = load_tool_tiers_from_dir(&tmp).unwrap();
295        assert_eq!(config.default_tier, ToolTier::T1);
296        assert!(config.tools.is_empty());
297        assert_eq!(config.tiers.len(), 5);
298        let _ = std::fs::remove_dir_all(&tmp);
299    }
300
301    #[test]
302    fn save_and_load_roundtrip() {
303        let tmp =
304            std::env::temp_dir().join(format!("edda_tool_tier_io_test_{}", std::process::id()));
305        let _ = std::fs::remove_dir_all(&tmp);
306        std::fs::create_dir_all(&tmp).unwrap();
307
308        let mut config = default_tool_tier_config();
309        config.tools.insert("curl".to_string(), ToolTier::T2);
310
311        save_tool_tiers_to_dir(&tmp, &config).unwrap();
312        let loaded = load_tool_tiers_from_dir(&tmp).unwrap();
313
314        assert_eq!(loaded.tools.get("curl"), Some(&ToolTier::T2));
315        assert_eq!(loaded.default_tier, ToolTier::T1);
316        assert_eq!(loaded.tiers.len(), 5);
317
318        let _ = std::fs::remove_dir_all(&tmp);
319    }
320
321    #[test]
322    fn tool_tier_display_and_parse() {
323        for tier in [
324            ToolTier::T0,
325            ToolTier::T1,
326            ToolTier::T2,
327            ToolTier::T3,
328            ToolTier::T4,
329        ] {
330            let s = tier.to_string();
331            let parsed: ToolTier = s.parse().unwrap();
332            assert_eq!(parsed, tier);
333        }
334        // case-insensitive
335        assert_eq!("t2".parse::<ToolTier>().unwrap(), ToolTier::T2);
336        // invalid
337        assert!("T5".parse::<ToolTier>().is_err());
338    }
339
340    #[test]
341    fn resolve_with_t4_forbidden() {
342        let mut config = default_tool_tier_config();
343        config
344            .tools
345            .insert("git_push_force".to_string(), ToolTier::T4);
346
347        let result = resolve_tool_tier(&config, "git_push_force");
348        assert_eq!(result.tier, ToolTier::T4);
349        assert_eq!(result.approval, ApprovalRequirement::Blocked);
350    }
351}