Skip to main content

t_ron/
lib.rs

1//! # T-Ron — MCP Security Monitor
2//!
3//! T-Ron (the security program that fights the MCP) provides real-time
4//! monitoring, auditing, and threat detection for MCP tool calls across
5//! the AGNOS ecosystem.
6//!
7//! ## Architecture
8//!
9//! ```text
10//! Agent → bote (MCP protocol) → t-ron (security gate) → tool handler
11//!                                  ├── policy check
12//!                                  ├── rate limiting
13//!                                  ├── payload scanning
14//!                                  ├── pattern analysis
15//!                                  └── audit logging (libro)
16//! ```
17
18pub mod audit;
19pub mod gate;
20pub mod middleware;
21pub mod pattern;
22pub mod policy;
23pub mod query;
24pub mod rate;
25pub mod scanner;
26pub mod score;
27pub mod tools;
28
29mod error;
30pub use error::TRonError;
31
32use std::sync::Arc;
33
34/// Top-level MCP security monitor.
35pub struct TRon {
36    policy: Arc<policy::PolicyEngine>,
37    rate_limiter: Arc<rate::RateLimiter>,
38    pattern: Arc<pattern::PatternAnalyzer>,
39    audit: Arc<audit::AuditLogger>,
40    config: TRonConfig,
41}
42
43/// Configuration for t-ron.
44#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
45pub struct TRonConfig {
46    /// Default action for unknown agents.
47    pub default_unknown_agent: DefaultAction,
48    /// Default action for unknown tools.
49    pub default_unknown_tool: DefaultAction,
50    /// Maximum parameter size in bytes.
51    pub max_param_size_bytes: usize,
52    /// Enable payload scanning.
53    pub scan_payloads: bool,
54    /// Enable pattern analysis.
55    pub analyze_patterns: bool,
56}
57
58/// Default action for unmatched requests.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
60pub enum DefaultAction {
61    Allow,
62    Deny,
63    Flag,
64}
65
66impl Default for TRonConfig {
67    fn default() -> Self {
68        Self {
69            default_unknown_agent: DefaultAction::Deny,
70            default_unknown_tool: DefaultAction::Deny,
71            max_param_size_bytes: 65536,
72            scan_payloads: true,
73            analyze_patterns: true,
74        }
75    }
76}
77
78impl TRon {
79    /// Create a new t-ron security monitor.
80    pub fn new(config: TRonConfig) -> Self {
81        Self {
82            policy: Arc::new(policy::PolicyEngine::new()),
83            rate_limiter: Arc::new(rate::RateLimiter::new()),
84            pattern: Arc::new(pattern::PatternAnalyzer::new()),
85            audit: Arc::new(audit::AuditLogger::new()),
86            config,
87        }
88    }
89
90    /// Check if a tool call is permitted.
91    pub async fn check(&self, call: &gate::ToolCall) -> gate::Verdict {
92        // 1. Check param size
93        let param_str = call.params.to_string();
94        if param_str.len() > self.config.max_param_size_bytes {
95            let verdict = gate::Verdict::Deny {
96                reason: format!(
97                    "parameter size {} exceeds limit {}",
98                    param_str.len(),
99                    self.config.max_param_size_bytes
100                ),
101                code: gate::DenyCode::ParameterTooLarge,
102            };
103            self.audit.log(call, &verdict).await;
104            return verdict;
105        }
106
107        // 2. Check policy (ACL)
108        match self.policy.check(&call.agent_id, &call.tool_name) {
109            policy::PolicyResult::Allow => {}
110            policy::PolicyResult::Deny(reason) => {
111                let verdict = gate::Verdict::Deny {
112                    reason,
113                    code: gate::DenyCode::Unauthorized,
114                };
115                self.audit.log(call, &verdict).await;
116                return verdict;
117            }
118            policy::PolicyResult::UnknownAgent => {
119                if let Some(v) = default_action_verdict(
120                    self.config.default_unknown_agent,
121                    "unknown agent".to_string(),
122                ) {
123                    self.audit.log(call, &v).await;
124                    return v;
125                }
126            }
127            policy::PolicyResult::UnknownTool => {
128                if let Some(v) = default_action_verdict(
129                    self.config.default_unknown_tool,
130                    format!(
131                        "tool '{}' not in policy for agent '{}'",
132                        call.tool_name, call.agent_id
133                    ),
134                ) {
135                    self.audit.log(call, &v).await;
136                    return v;
137                }
138            }
139        }
140
141        // 3. Rate limit check
142        if !self.rate_limiter.check(&call.agent_id, &call.tool_name) {
143            let verdict = gate::Verdict::Deny {
144                reason: "rate limit exceeded".to_string(),
145                code: gate::DenyCode::RateLimited,
146            };
147            self.audit.log(call, &verdict).await;
148            return verdict;
149        }
150
151        // 4. Payload scanning
152        if self.config.scan_payloads
153            && let Some(threat) = scanner::scan(&call.params)
154        {
155            let verdict = gate::Verdict::Deny {
156                reason: format!("injection detected: {threat}"),
157                code: gate::DenyCode::InjectionDetected,
158            };
159            self.audit.log(call, &verdict).await;
160            return verdict;
161        }
162
163        // 5. Pattern analysis
164        if self.config.analyze_patterns {
165            self.pattern.record(call);
166            if let Some(anomaly) = self.pattern.check_anomaly(&call.agent_id) {
167                let verdict = gate::Verdict::Flag {
168                    reason: format!("anomalous pattern: {anomaly}"),
169                };
170                self.audit.log(call, &verdict).await;
171                return verdict;
172            }
173        }
174
175        // All checks passed
176        let verdict = gate::Verdict::Allow;
177        self.audit.log(call, &verdict).await;
178        verdict
179    }
180
181    /// Load policy from TOML string.
182    pub fn load_policy(&self, toml_str: &str) -> Result<(), TRonError> {
183        self.policy.load_toml(toml_str)
184    }
185
186    /// Get the query API (for T.Ron personality in SecureYeoman).
187    pub fn query(&self) -> query::TRonQuery {
188        query::TRonQuery {
189            audit: self.audit.clone(),
190        }
191    }
192
193    /// Get a shared reference to the policy engine (for tool handlers).
194    pub fn policy_arc(&self) -> Arc<policy::PolicyEngine> {
195        self.policy.clone()
196    }
197}
198
199/// Convert a `DefaultAction` + reason into a verdict, or `None` for `Allow`.
200fn default_action_verdict(action: DefaultAction, reason: String) -> Option<gate::Verdict> {
201    match action {
202        DefaultAction::Deny => Some(gate::Verdict::Deny {
203            reason,
204            code: gate::DenyCode::Unauthorized,
205        }),
206        DefaultAction::Flag => Some(gate::Verdict::Flag { reason }),
207        DefaultAction::Allow => None,
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn default_config() {
217        let config = TRonConfig::default();
218        assert_eq!(config.default_unknown_agent, DefaultAction::Deny);
219        assert_eq!(config.max_param_size_bytes, 65536);
220        assert!(config.scan_payloads);
221    }
222
223    #[tokio::test]
224    async fn deny_unknown_agent() {
225        let tron = TRon::new(TRonConfig::default());
226        let call = gate::ToolCall {
227            agent_id: "unknown-agent".to_string(),
228            tool_name: "some_tool".to_string(),
229            params: serde_json::json!({}),
230            timestamp: chrono::Utc::now(),
231        };
232        let verdict = tron.check(&call).await;
233        assert!(matches!(verdict, gate::Verdict::Deny { .. }));
234    }
235
236    #[tokio::test]
237    async fn deny_oversized_params() {
238        let config = TRonConfig {
239            max_param_size_bytes: 10,
240            default_unknown_agent: DefaultAction::Allow,
241            ..Default::default()
242        };
243        let tron = TRon::new(config);
244        let call = gate::ToolCall {
245            agent_id: "agent".to_string(),
246            tool_name: "tool".to_string(),
247            params: serde_json::json!({"data": "this is way more than 10 bytes of parameter data"}),
248            timestamp: chrono::Utc::now(),
249        };
250        let verdict = tron.check(&call).await;
251        assert!(matches!(
252            verdict,
253            gate::Verdict::Deny {
254                code: gate::DenyCode::ParameterTooLarge,
255                ..
256            }
257        ));
258    }
259
260    #[tokio::test]
261    async fn allow_known_agent_known_tool() {
262        let tron = TRon::new(TRonConfig::default());
263        tron.load_policy(
264            r#"
265[agent."web-agent"]
266allow = ["tarang_*"]
267"#,
268        )
269        .unwrap();
270        let call = gate::ToolCall {
271            agent_id: "web-agent".to_string(),
272            tool_name: "tarang_probe".to_string(),
273            params: serde_json::json!({"path": "/test"}),
274            timestamp: chrono::Utc::now(),
275        };
276        let verdict = tron.check(&call).await;
277        assert!(verdict.is_allowed());
278        assert!(!verdict.is_denied());
279    }
280
281    #[tokio::test]
282    async fn flag_unknown_agent() {
283        let config = TRonConfig {
284            default_unknown_agent: DefaultAction::Flag,
285            ..Default::default()
286        };
287        let tron = TRon::new(config);
288        let call = gate::ToolCall {
289            agent_id: "mystery".to_string(),
290            tool_name: "tool".to_string(),
291            params: serde_json::json!({}),
292            timestamp: chrono::Utc::now(),
293        };
294        let verdict = tron.check(&call).await;
295        assert!(matches!(verdict, gate::Verdict::Flag { .. }));
296        assert!(verdict.is_allowed()); // Flags are allowed
297    }
298
299    #[tokio::test]
300    async fn deny_unknown_tool_for_known_agent() {
301        let tron = TRon::new(TRonConfig::default());
302        tron.load_policy(
303            r#"
304[agent."limited"]
305allow = ["tarang_*"]
306"#,
307        )
308        .unwrap();
309        let call = gate::ToolCall {
310            agent_id: "limited".to_string(),
311            tool_name: "aegis_scan".to_string(), // Not in allow list
312            params: serde_json::json!({}),
313            timestamp: chrono::Utc::now(),
314        };
315        let verdict = tron.check(&call).await;
316        assert!(verdict.is_denied());
317    }
318
319    #[tokio::test]
320    async fn flag_unknown_tool() {
321        let config = TRonConfig {
322            default_unknown_tool: DefaultAction::Flag,
323            ..Default::default()
324        };
325        let tron = TRon::new(config);
326        tron.load_policy(
327            r#"
328[agent."agent-1"]
329allow = ["tarang_*"]
330"#,
331        )
332        .unwrap();
333        let call = gate::ToolCall {
334            agent_id: "agent-1".to_string(),
335            tool_name: "rasa_edit".to_string(),
336            params: serde_json::json!({}),
337            timestamp: chrono::Utc::now(),
338        };
339        let verdict = tron.check(&call).await;
340        assert!(matches!(verdict, gate::Verdict::Flag { .. }));
341    }
342
343    #[tokio::test]
344    async fn allow_unknown_agent_passthrough() {
345        let config = TRonConfig {
346            default_unknown_agent: DefaultAction::Allow,
347            default_unknown_tool: DefaultAction::Allow,
348            ..Default::default()
349        };
350        let tron = TRon::new(config);
351        let call = gate::ToolCall {
352            agent_id: "whoever".to_string(),
353            tool_name: "whatever".to_string(),
354            params: serde_json::json!({"safe": true}),
355            timestamp: chrono::Utc::now(),
356        };
357        let verdict = tron.check(&call).await;
358        assert!(verdict.is_allowed());
359    }
360
361    #[tokio::test]
362    async fn deny_injection_through_pipeline() {
363        let config = TRonConfig {
364            default_unknown_agent: DefaultAction::Allow,
365            default_unknown_tool: DefaultAction::Allow,
366            ..Default::default()
367        };
368        let tron = TRon::new(config);
369        let call = gate::ToolCall {
370            agent_id: "agent".to_string(),
371            tool_name: "tool".to_string(),
372            params: serde_json::json!({"q": "1 UNION SELECT * FROM passwords"}),
373            timestamp: chrono::Utc::now(),
374        };
375        let verdict = tron.check(&call).await;
376        assert!(matches!(
377            verdict,
378            gate::Verdict::Deny {
379                code: gate::DenyCode::InjectionDetected,
380                ..
381            }
382        ));
383    }
384
385    #[tokio::test]
386    async fn scan_payloads_disabled_bypass() {
387        let config = TRonConfig {
388            default_unknown_agent: DefaultAction::Allow,
389            default_unknown_tool: DefaultAction::Allow,
390            scan_payloads: false,
391            ..Default::default()
392        };
393        let tron = TRon::new(config);
394        let call = gate::ToolCall {
395            agent_id: "agent".to_string(),
396            tool_name: "tool".to_string(),
397            params: serde_json::json!({"q": "1 UNION SELECT * FROM passwords"}),
398            timestamp: chrono::Utc::now(),
399        };
400        // With scanning disabled, injection payload should pass
401        let verdict = tron.check(&call).await;
402        assert!(verdict.is_allowed());
403    }
404
405    #[tokio::test]
406    async fn analyze_patterns_disabled_bypass() {
407        let config = TRonConfig {
408            default_unknown_agent: DefaultAction::Allow,
409            default_unknown_tool: DefaultAction::Allow,
410            analyze_patterns: false,
411            ..Default::default()
412        };
413        let tron = TRon::new(config);
414        // Even with 20 distinct tools, no anomaly should be flagged
415        for i in 0..20 {
416            let call = gate::ToolCall {
417                agent_id: "agent".to_string(),
418                tool_name: format!("tool_{i}"),
419                params: serde_json::json!({}),
420                timestamp: chrono::Utc::now(),
421            };
422            let verdict = tron.check(&call).await;
423            assert!(verdict.is_allowed());
424        }
425    }
426
427    #[tokio::test]
428    async fn rate_limit_through_pipeline() {
429        let config = TRonConfig {
430            default_unknown_agent: DefaultAction::Allow,
431            default_unknown_tool: DefaultAction::Allow,
432            scan_payloads: false,
433            analyze_patterns: false,
434            ..Default::default()
435        };
436        let tron = TRon::new(config);
437        let call = gate::ToolCall {
438            agent_id: "agent".to_string(),
439            tool_name: "tool".to_string(),
440            params: serde_json::json!({}),
441            timestamp: chrono::Utc::now(),
442        };
443        for _ in 0..60 {
444            let v = tron.check(&call).await;
445            assert!(v.is_allowed());
446        }
447        // 61st should be rate limited
448        let v = tron.check(&call).await;
449        assert!(matches!(
450            v,
451            gate::Verdict::Deny {
452                code: gate::DenyCode::RateLimited,
453                ..
454            }
455        ));
456    }
457
458    #[tokio::test]
459    async fn policy_deny_through_pipeline() {
460        let tron = TRon::new(TRonConfig::default());
461        tron.load_policy(
462            r#"
463[agent."restricted"]
464allow = ["tarang_*"]
465deny = ["tarang_delete"]
466"#,
467        )
468        .unwrap();
469        let call = gate::ToolCall {
470            agent_id: "restricted".to_string(),
471            tool_name: "tarang_delete".to_string(),
472            params: serde_json::json!({}),
473            timestamp: chrono::Utc::now(),
474        };
475        let verdict = tron.check(&call).await;
476        assert!(verdict.is_denied());
477    }
478
479    #[tokio::test]
480    async fn load_policy_error() {
481        let tron = TRon::new(TRonConfig::default());
482        assert!(tron.load_policy("not valid toml {{{").is_err());
483    }
484
485    #[tokio::test]
486    async fn audit_logged_for_every_verdict() {
487        let config = TRonConfig {
488            default_unknown_agent: DefaultAction::Allow,
489            default_unknown_tool: DefaultAction::Allow,
490            scan_payloads: false,
491            analyze_patterns: false,
492            ..Default::default()
493        };
494        let tron = TRon::new(config);
495        let call = gate::ToolCall {
496            agent_id: "agent".to_string(),
497            tool_name: "tool".to_string(),
498            params: serde_json::json!({}),
499            timestamp: chrono::Utc::now(),
500        };
501        tron.check(&call).await;
502        tron.check(&call).await;
503
504        let query = tron.query();
505        assert_eq!(query.total_events().await, 2);
506    }
507}