Skip to main content

t_ron/
policy.rs

1//! Tool policy engine — per-agent ACLs.
2
3use crate::TRonError;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::sync::RwLock;
7
8pub enum PolicyResult {
9    Allow,
10    Deny(String),
11    /// Agent has no policy entry at all.
12    UnknownAgent,
13    /// Agent exists but tool didn't match any allow/deny pattern.
14    UnknownTool,
15}
16
17/// Per-agent tool policy.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct AgentPolicy {
20    #[serde(default)]
21    pub allow: Vec<String>,
22    #[serde(default)]
23    pub deny: Vec<String>,
24    // TODO: wire into RateLimiter — parsed but not yet enforced
25    // #[serde(default)]
26    // pub rate_limit: Option<RateLimitPolicy>,
27}
28
29// TODO: wire into RateLimiter — parsed but not yet enforced
30// #[derive(Debug, Clone, Serialize, Deserialize)]
31// pub struct RateLimitPolicy {
32//     pub calls_per_minute: u64,
33// }
34
35/// Policy configuration loaded from TOML.
36#[derive(Debug, Default, Serialize, Deserialize)]
37pub struct PolicyConfig {
38    #[serde(default)]
39    pub agent: HashMap<String, AgentPolicy>,
40}
41
42pub struct PolicyEngine {
43    config: RwLock<PolicyConfig>,
44}
45
46impl Default for PolicyEngine {
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52impl PolicyEngine {
53    pub fn new() -> Self {
54        Self {
55            config: RwLock::new(PolicyConfig::default()),
56        }
57    }
58
59    /// Load policy from TOML string.
60    pub fn load_toml(&self, toml_str: &str) -> Result<(), TRonError> {
61        let config: PolicyConfig =
62            toml::from_str(toml_str).map_err(|e| TRonError::PolicyConfig(e.to_string()))?;
63        *self.config.write().expect("policy lock poisoned") = config;
64        Ok(())
65    }
66
67    /// Check if an agent is allowed to call a tool.
68    pub fn check(&self, agent_id: &str, tool_name: &str) -> PolicyResult {
69        let config = self.config.read().expect("policy lock poisoned");
70
71        let policy = match config.agent.get(agent_id) {
72            Some(p) => p,
73            None => return PolicyResult::UnknownAgent,
74        };
75
76        // Check deny list first (deny wins over allow)
77        for pattern in &policy.deny {
78            if matches_glob(pattern, tool_name) {
79                return PolicyResult::Deny(format!(
80                    "tool '{tool_name}' denied by policy for agent '{agent_id}'"
81                ));
82            }
83        }
84
85        // Check allow list
86        for pattern in &policy.allow {
87            if matches_glob(pattern, tool_name) {
88                return PolicyResult::Allow;
89            }
90        }
91
92        // Agent exists but tool not in any list
93        PolicyResult::UnknownTool
94    }
95
96    /// Grant an agent access to tools matching a pattern.
97    pub fn grant(&self, agent_id: &str, pattern: &str) {
98        let mut config = self.config.write().expect("policy lock poisoned");
99        let policy = config
100            .agent
101            .entry(agent_id.to_string())
102            .or_insert_with(|| AgentPolicy {
103                allow: vec![],
104                deny: vec![],
105            });
106        policy.allow.push(pattern.to_string());
107    }
108
109    /// Revoke an agent's access to tools matching a pattern.
110    pub fn revoke(&self, agent_id: &str, pattern: &str) {
111        let mut config = self.config.write().expect("policy lock poisoned");
112        let policy = config
113            .agent
114            .entry(agent_id.to_string())
115            .or_insert_with(|| AgentPolicy {
116                allow: vec![],
117                deny: vec![],
118            });
119        policy.deny.push(pattern.to_string());
120    }
121}
122
123/// Simple glob matching: "tarang_*" matches "tarang_probe".
124fn matches_glob(pattern: &str, name: &str) -> bool {
125    if pattern == "*" {
126        return true;
127    }
128    if let Some(prefix) = pattern.strip_suffix('*') {
129        name.starts_with(prefix)
130    } else {
131        pattern == name
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn glob_wildcard() {
141        assert!(matches_glob("*", "anything"));
142        assert!(matches_glob("tarang_*", "tarang_probe"));
143        assert!(matches_glob("tarang_*", "tarang_analyze"));
144        assert!(!matches_glob("tarang_*", "rasa_edit"));
145        assert!(matches_glob("aegis_quarantine", "aegis_quarantine"));
146        assert!(!matches_glob("aegis_quarantine", "aegis_scan"));
147    }
148
149    #[test]
150    fn policy_deny_wins() {
151        let engine = PolicyEngine::new();
152        engine.grant("agent-1", "tarang_*");
153        engine.revoke("agent-1", "tarang_delete");
154
155        assert!(matches!(
156            engine.check("agent-1", "tarang_probe"),
157            PolicyResult::Allow
158        ));
159        assert!(matches!(
160            engine.check("agent-1", "tarang_delete"),
161            PolicyResult::Deny(_)
162        ));
163    }
164
165    #[test]
166    fn unknown_agent() {
167        let engine = PolicyEngine::new();
168        assert!(matches!(
169            engine.check("nobody", "any_tool"),
170            PolicyResult::UnknownAgent
171        ));
172    }
173
174    #[test]
175    fn load_toml_policy() {
176        let engine = PolicyEngine::new();
177        let toml = r#"
178[agent."web-agent"]
179allow = ["tarang_*", "rasa_*"]
180deny = ["aegis_*"]
181"#;
182        engine.load_toml(toml).unwrap();
183        assert!(matches!(
184            engine.check("web-agent", "tarang_probe"),
185            PolicyResult::Allow
186        ));
187        assert!(matches!(
188            engine.check("web-agent", "aegis_scan"),
189            PolicyResult::Deny(_)
190        ));
191    }
192
193    #[test]
194    fn unknown_tool_for_known_agent() {
195        let engine = PolicyEngine::new();
196        engine.grant("agent-1", "tarang_*");
197        // Agent exists but tool doesn't match any pattern
198        assert!(matches!(
199            engine.check("agent-1", "rasa_edit"),
200            PolicyResult::UnknownTool
201        ));
202    }
203
204    #[test]
205    fn malformed_toml_error() {
206        let engine = PolicyEngine::new();
207        let result = engine.load_toml("this is not valid toml {{{}}}");
208        assert!(result.is_err());
209    }
210
211    #[test]
212    fn deny_only_policy() {
213        let engine = PolicyEngine::new();
214        let toml = r#"
215[agent."lockdown"]
216deny = ["*"]
217"#;
218        engine.load_toml(toml).unwrap();
219        assert!(matches!(
220            engine.check("lockdown", "anything"),
221            PolicyResult::Deny(_)
222        ));
223    }
224
225    #[test]
226    fn allow_only_policy() {
227        let engine = PolicyEngine::new();
228        let toml = r#"
229[agent."open"]
230allow = ["*"]
231"#;
232        engine.load_toml(toml).unwrap();
233        assert!(matches!(
234            engine.check("open", "anything"),
235            PolicyResult::Allow
236        ));
237    }
238
239    #[test]
240    fn reload_policy_replaces_previous() {
241        let engine = PolicyEngine::new();
242        engine.grant("agent-1", "tarang_*");
243        assert!(matches!(
244            engine.check("agent-1", "tarang_probe"),
245            PolicyResult::Allow
246        ));
247
248        // Reload with empty policy — agent-1 no longer exists
249        engine.load_toml("").unwrap();
250        assert!(matches!(
251            engine.check("agent-1", "tarang_probe"),
252            PolicyResult::UnknownAgent
253        ));
254    }
255
256    #[test]
257    fn multiple_agents_in_policy() {
258        let engine = PolicyEngine::new();
259        let toml = r#"
260[agent."reader"]
261allow = ["tarang_*"]
262
263[agent."admin"]
264allow = ["*"]
265deny = ["ark_remove"]
266"#;
267        engine.load_toml(toml).unwrap();
268        assert!(matches!(
269            engine.check("reader", "tarang_probe"),
270            PolicyResult::Allow
271        ));
272        assert!(matches!(
273            engine.check("reader", "aegis_scan"),
274            PolicyResult::UnknownTool
275        ));
276        assert!(matches!(
277            engine.check("admin", "aegis_scan"),
278            PolicyResult::Allow
279        ));
280        assert!(matches!(
281            engine.check("admin", "ark_remove"),
282            PolicyResult::Deny(_)
283        ));
284    }
285
286    #[test]
287    fn empty_pattern_no_match() {
288        assert!(!matches_glob("", "anything"));
289        assert!(matches_glob("", ""));
290    }
291
292    #[test]
293    fn glob_star_suffix_only() {
294        // Leading star is not supported — treated as literal
295        assert!(!matches_glob("*_delete", "tarang_delete"));
296    }
297}