1use crate::TRonError;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::sync::RwLock;
7
8pub enum PolicyResult {
9 Allow,
10 Deny(String),
11 UnknownAgent,
13 UnknownTool,
15}
16
17#[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 }
28
29#[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 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 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 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 for pattern in &policy.allow {
87 if matches_glob(pattern, tool_name) {
88 return PolicyResult::Allow;
89 }
90 }
91
92 PolicyResult::UnknownTool
94 }
95
96 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 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
123fn 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 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 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 assert!(!matches_glob("*_delete", "tarang_delete"));
296 }
297}