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::path::PathBuf;
33use std::sync::Arc;
34
35/// Top-level MCP security monitor.
36pub struct TRon {
37    policy: Arc<policy::PolicyEngine>,
38    rate_limiter: Arc<rate::RateLimiter>,
39    pattern: Arc<pattern::PatternAnalyzer>,
40    audit: Arc<audit::AuditLogger>,
41    config: TRonConfig,
42    /// Stored policy file path for reload support.
43    policy_path: std::sync::Mutex<Option<PathBuf>>,
44}
45
46/// Configuration for t-ron.
47#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
48pub struct TRonConfig {
49    /// Default action for unknown agents.
50    pub default_unknown_agent: DefaultAction,
51    /// Default action for unknown tools.
52    pub default_unknown_tool: DefaultAction,
53    /// Maximum parameter size in bytes.
54    pub max_param_size_bytes: usize,
55    /// Enable payload scanning.
56    pub scan_payloads: bool,
57    /// Enable pattern analysis.
58    pub analyze_patterns: bool,
59}
60
61/// Default action for unmatched requests.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
63#[non_exhaustive]
64pub enum DefaultAction {
65    Allow,
66    Deny,
67    Flag,
68}
69
70impl Default for TRonConfig {
71    fn default() -> Self {
72        Self {
73            default_unknown_agent: DefaultAction::Deny,
74            default_unknown_tool: DefaultAction::Deny,
75            max_param_size_bytes: 65536,
76            scan_payloads: true,
77            analyze_patterns: true,
78        }
79    }
80}
81
82impl TRon {
83    /// Create a new t-ron security monitor.
84    #[must_use]
85    pub fn new(config: TRonConfig) -> Self {
86        Self {
87            policy: Arc::new(policy::PolicyEngine::new()),
88            rate_limiter: Arc::new(rate::RateLimiter::new()),
89            pattern: Arc::new(pattern::PatternAnalyzer::new()),
90            audit: Arc::new(audit::AuditLogger::new()),
91            config,
92            policy_path: std::sync::Mutex::new(None),
93        }
94    }
95
96    /// Check if a tool call is permitted.
97    pub async fn check(&self, call: &gate::ToolCall) -> gate::Verdict {
98        // 1. Check param size (counting writer avoids allocating the serialized string)
99        let param_size = {
100            let mut counter = ByteCounter(0);
101            // serde_json::to_writer on Value with a non-failing writer is infallible
102            let _ = serde_json::to_writer(&mut counter, &call.params);
103            counter.0
104        };
105        if param_size > self.config.max_param_size_bytes {
106            let verdict = gate::Verdict::Deny {
107                reason: format!(
108                    "parameter size {} exceeds limit {}",
109                    param_size, self.config.max_param_size_bytes
110                ),
111                code: gate::DenyCode::ParameterTooLarge,
112            };
113            self.audit.log(call, &verdict).await;
114            return verdict;
115        }
116
117        // 2. Check policy (ACL)
118        match self.policy.check(&call.agent_id, &call.tool_name) {
119            policy::PolicyResult::Allow => {}
120            policy::PolicyResult::Deny(reason) => {
121                let verdict = gate::Verdict::Deny {
122                    reason,
123                    code: gate::DenyCode::Unauthorized,
124                };
125                self.audit.log(call, &verdict).await;
126                return verdict;
127            }
128            policy::PolicyResult::UnknownAgent => {
129                if let Some(v) = default_action_verdict(
130                    self.config.default_unknown_agent,
131                    "unknown agent".to_string(),
132                ) {
133                    self.audit.log(call, &v).await;
134                    return v;
135                }
136            }
137            policy::PolicyResult::UnknownTool => {
138                if let Some(v) = default_action_verdict(
139                    self.config.default_unknown_tool,
140                    format!(
141                        "tool '{}' not in policy for agent '{}'",
142                        call.tool_name, call.agent_id
143                    ),
144                ) {
145                    self.audit.log(call, &v).await;
146                    return v;
147                }
148            }
149        }
150
151        // 3. Rate limit check
152        if !self.rate_limiter.check(&call.agent_id, &call.tool_name) {
153            let verdict = gate::Verdict::Deny {
154                reason: "rate limit exceeded".to_string(),
155                code: gate::DenyCode::RateLimited,
156            };
157            self.audit.log(call, &verdict).await;
158            return verdict;
159        }
160
161        // 4. Payload scanning
162        if self.config.scan_payloads
163            && let Some(threat) = scanner::scan(&call.params)
164        {
165            let verdict = gate::Verdict::Deny {
166                reason: format!("injection detected: {threat}"),
167                code: gate::DenyCode::InjectionDetected,
168            };
169            self.audit.log(call, &verdict).await;
170            return verdict;
171        }
172
173        // 5. Pattern analysis
174        if self.config.analyze_patterns {
175            self.pattern.record(call);
176            if let Some(anomaly) = self.pattern.check_anomaly(&call.agent_id) {
177                let verdict = gate::Verdict::Flag {
178                    reason: format!("anomalous pattern: {anomaly}"),
179                };
180                self.audit.log(call, &verdict).await;
181                return verdict;
182            }
183        }
184
185        // All checks passed
186        let verdict = gate::Verdict::Allow;
187        self.audit.log(call, &verdict).await;
188        verdict
189    }
190
191    /// Load policy from TOML string and apply rate limits.
192    pub fn load_policy(&self, toml_str: &str) -> Result<(), TRonError> {
193        self.policy.load_toml(toml_str)?;
194        self.apply_rate_limits();
195        Ok(())
196    }
197
198    /// Load policy from a file and store the path for hot-reload.
199    pub fn load_policy_file(&self, path: impl Into<PathBuf>) -> Result<(), TRonError> {
200        let path = path.into();
201        let content = std::fs::read_to_string(&path)?;
202        self.load_policy(&content)?;
203        *self.policy_path.lock().unwrap_or_else(|p| p.into_inner()) = Some(path);
204        Ok(())
205    }
206
207    /// Reload policy from the previously loaded file path.
208    ///
209    /// Designed for SIGHUP handlers — call this when the process receives a
210    /// reload signal. Returns an error if no file was previously loaded.
211    pub fn reload_policy(&self) -> Result<(), TRonError> {
212        let path = self
213            .policy_path
214            .lock()
215            .unwrap_or_else(|p| p.into_inner())
216            .clone();
217        match path {
218            Some(p) => {
219                tracing::info!(path = %p.display(), "reloading policy from file");
220                let content = std::fs::read_to_string(&p)?;
221                self.load_policy(&content)
222            }
223            None => Err(TRonError::Policy(
224                "no policy file path set — use load_policy_file first".into(),
225            )),
226        }
227    }
228
229    /// Apply per-agent rate limits from the loaded policy config.
230    fn apply_rate_limits(&self) {
231        let config = self.policy.config();
232        for (agent_id, agent_policy) in &config.agent {
233            if let Some(ref rl) = agent_policy.rate_limit {
234                tracing::debug!(
235                    agent = agent_id,
236                    cpm = rl.calls_per_minute,
237                    "applying rate limit from policy"
238                );
239                self.rate_limiter.set_rate(agent_id, rl.calls_per_minute);
240            }
241        }
242    }
243
244    /// Get the query API (for T.Ron personality in SecureYeoman).
245    #[must_use]
246    pub fn query(&self) -> query::TRonQuery {
247        query::TRonQuery {
248            audit: self.audit.clone(),
249        }
250    }
251
252    /// Get a shared reference to the policy engine (for tool handlers).
253    #[must_use]
254    pub fn policy_arc(&self) -> Arc<policy::PolicyEngine> {
255        self.policy.clone()
256    }
257}
258
259/// Counts bytes written without allocating a buffer.
260struct ByteCounter(usize);
261
262impl std::io::Write for ByteCounter {
263    #[inline]
264    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
265        self.0 += buf.len();
266        Ok(buf.len())
267    }
268
269    #[inline]
270    fn flush(&mut self) -> std::io::Result<()> {
271        Ok(())
272    }
273}
274
275/// Convert a `DefaultAction` + reason into a verdict, or `None` for `Allow`.
276fn default_action_verdict(action: DefaultAction, reason: String) -> Option<gate::Verdict> {
277    match action {
278        DefaultAction::Deny => Some(gate::Verdict::Deny {
279            reason,
280            code: gate::DenyCode::Unauthorized,
281        }),
282        DefaultAction::Flag => Some(gate::Verdict::Flag { reason }),
283        DefaultAction::Allow => None,
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn default_config() {
293        let config = TRonConfig::default();
294        assert_eq!(config.default_unknown_agent, DefaultAction::Deny);
295        assert_eq!(config.max_param_size_bytes, 65536);
296        assert!(config.scan_payloads);
297    }
298
299    #[tokio::test]
300    async fn deny_unknown_agent() {
301        let tron = TRon::new(TRonConfig::default());
302        let call = gate::ToolCall {
303            agent_id: "unknown-agent".to_string(),
304            tool_name: "some_tool".to_string(),
305            params: serde_json::json!({}),
306            timestamp: chrono::Utc::now(),
307        };
308        let verdict = tron.check(&call).await;
309        assert!(matches!(verdict, gate::Verdict::Deny { .. }));
310    }
311
312    #[tokio::test]
313    async fn deny_oversized_params() {
314        let config = TRonConfig {
315            max_param_size_bytes: 10,
316            default_unknown_agent: DefaultAction::Allow,
317            ..Default::default()
318        };
319        let tron = TRon::new(config);
320        let call = gate::ToolCall {
321            agent_id: "agent".to_string(),
322            tool_name: "tool".to_string(),
323            params: serde_json::json!({"data": "this is way more than 10 bytes of parameter data"}),
324            timestamp: chrono::Utc::now(),
325        };
326        let verdict = tron.check(&call).await;
327        assert!(matches!(
328            verdict,
329            gate::Verdict::Deny {
330                code: gate::DenyCode::ParameterTooLarge,
331                ..
332            }
333        ));
334    }
335
336    #[tokio::test]
337    async fn allow_known_agent_known_tool() {
338        let tron = TRon::new(TRonConfig::default());
339        tron.load_policy(
340            r#"
341[agent."web-agent"]
342allow = ["tarang_*"]
343"#,
344        )
345        .unwrap();
346        let call = gate::ToolCall {
347            agent_id: "web-agent".to_string(),
348            tool_name: "tarang_probe".to_string(),
349            params: serde_json::json!({"path": "/test"}),
350            timestamp: chrono::Utc::now(),
351        };
352        let verdict = tron.check(&call).await;
353        assert!(verdict.is_allowed());
354        assert!(!verdict.is_denied());
355    }
356
357    #[tokio::test]
358    async fn flag_unknown_agent() {
359        let config = TRonConfig {
360            default_unknown_agent: DefaultAction::Flag,
361            ..Default::default()
362        };
363        let tron = TRon::new(config);
364        let call = gate::ToolCall {
365            agent_id: "mystery".to_string(),
366            tool_name: "tool".to_string(),
367            params: serde_json::json!({}),
368            timestamp: chrono::Utc::now(),
369        };
370        let verdict = tron.check(&call).await;
371        assert!(matches!(verdict, gate::Verdict::Flag { .. }));
372        assert!(verdict.is_allowed()); // Flags are allowed
373    }
374
375    #[tokio::test]
376    async fn deny_unknown_tool_for_known_agent() {
377        let tron = TRon::new(TRonConfig::default());
378        tron.load_policy(
379            r#"
380[agent."limited"]
381allow = ["tarang_*"]
382"#,
383        )
384        .unwrap();
385        let call = gate::ToolCall {
386            agent_id: "limited".to_string(),
387            tool_name: "aegis_scan".to_string(), // Not in allow list
388            params: serde_json::json!({}),
389            timestamp: chrono::Utc::now(),
390        };
391        let verdict = tron.check(&call).await;
392        assert!(verdict.is_denied());
393    }
394
395    #[tokio::test]
396    async fn flag_unknown_tool() {
397        let config = TRonConfig {
398            default_unknown_tool: DefaultAction::Flag,
399            ..Default::default()
400        };
401        let tron = TRon::new(config);
402        tron.load_policy(
403            r#"
404[agent."agent-1"]
405allow = ["tarang_*"]
406"#,
407        )
408        .unwrap();
409        let call = gate::ToolCall {
410            agent_id: "agent-1".to_string(),
411            tool_name: "rasa_edit".to_string(),
412            params: serde_json::json!({}),
413            timestamp: chrono::Utc::now(),
414        };
415        let verdict = tron.check(&call).await;
416        assert!(matches!(verdict, gate::Verdict::Flag { .. }));
417    }
418
419    #[tokio::test]
420    async fn allow_unknown_agent_passthrough() {
421        let config = TRonConfig {
422            default_unknown_agent: DefaultAction::Allow,
423            default_unknown_tool: DefaultAction::Allow,
424            ..Default::default()
425        };
426        let tron = TRon::new(config);
427        let call = gate::ToolCall {
428            agent_id: "whoever".to_string(),
429            tool_name: "whatever".to_string(),
430            params: serde_json::json!({"safe": true}),
431            timestamp: chrono::Utc::now(),
432        };
433        let verdict = tron.check(&call).await;
434        assert!(verdict.is_allowed());
435    }
436
437    #[tokio::test]
438    async fn deny_injection_through_pipeline() {
439        let config = TRonConfig {
440            default_unknown_agent: DefaultAction::Allow,
441            default_unknown_tool: DefaultAction::Allow,
442            ..Default::default()
443        };
444        let tron = TRon::new(config);
445        let call = gate::ToolCall {
446            agent_id: "agent".to_string(),
447            tool_name: "tool".to_string(),
448            params: serde_json::json!({"q": "1 UNION SELECT * FROM passwords"}),
449            timestamp: chrono::Utc::now(),
450        };
451        let verdict = tron.check(&call).await;
452        assert!(matches!(
453            verdict,
454            gate::Verdict::Deny {
455                code: gate::DenyCode::InjectionDetected,
456                ..
457            }
458        ));
459    }
460
461    #[tokio::test]
462    async fn scan_payloads_disabled_bypass() {
463        let config = TRonConfig {
464            default_unknown_agent: DefaultAction::Allow,
465            default_unknown_tool: DefaultAction::Allow,
466            scan_payloads: false,
467            ..Default::default()
468        };
469        let tron = TRon::new(config);
470        let call = gate::ToolCall {
471            agent_id: "agent".to_string(),
472            tool_name: "tool".to_string(),
473            params: serde_json::json!({"q": "1 UNION SELECT * FROM passwords"}),
474            timestamp: chrono::Utc::now(),
475        };
476        // With scanning disabled, injection payload should pass
477        let verdict = tron.check(&call).await;
478        assert!(verdict.is_allowed());
479    }
480
481    #[tokio::test]
482    async fn analyze_patterns_disabled_bypass() {
483        let config = TRonConfig {
484            default_unknown_agent: DefaultAction::Allow,
485            default_unknown_tool: DefaultAction::Allow,
486            analyze_patterns: false,
487            ..Default::default()
488        };
489        let tron = TRon::new(config);
490        // Even with 20 distinct tools, no anomaly should be flagged
491        for i in 0..20 {
492            let call = gate::ToolCall {
493                agent_id: "agent".to_string(),
494                tool_name: format!("tool_{i}"),
495                params: serde_json::json!({}),
496                timestamp: chrono::Utc::now(),
497            };
498            let verdict = tron.check(&call).await;
499            assert!(verdict.is_allowed());
500        }
501    }
502
503    #[tokio::test]
504    async fn rate_limit_through_pipeline() {
505        let config = TRonConfig {
506            default_unknown_agent: DefaultAction::Allow,
507            default_unknown_tool: DefaultAction::Allow,
508            scan_payloads: false,
509            analyze_patterns: false,
510            ..Default::default()
511        };
512        let tron = TRon::new(config);
513        let call = gate::ToolCall {
514            agent_id: "agent".to_string(),
515            tool_name: "tool".to_string(),
516            params: serde_json::json!({}),
517            timestamp: chrono::Utc::now(),
518        };
519        for _ in 0..60 {
520            let v = tron.check(&call).await;
521            assert!(v.is_allowed());
522        }
523        // 61st should be rate limited
524        let v = tron.check(&call).await;
525        assert!(matches!(
526            v,
527            gate::Verdict::Deny {
528                code: gate::DenyCode::RateLimited,
529                ..
530            }
531        ));
532    }
533
534    #[tokio::test]
535    async fn policy_deny_through_pipeline() {
536        let tron = TRon::new(TRonConfig::default());
537        tron.load_policy(
538            r#"
539[agent."restricted"]
540allow = ["tarang_*"]
541deny = ["tarang_delete"]
542"#,
543        )
544        .unwrap();
545        let call = gate::ToolCall {
546            agent_id: "restricted".to_string(),
547            tool_name: "tarang_delete".to_string(),
548            params: serde_json::json!({}),
549            timestamp: chrono::Utc::now(),
550        };
551        let verdict = tron.check(&call).await;
552        assert!(verdict.is_denied());
553    }
554
555    #[tokio::test]
556    async fn load_policy_error() {
557        let tron = TRon::new(TRonConfig::default());
558        assert!(tron.load_policy("not valid toml {{{").is_err());
559    }
560
561    #[tokio::test]
562    async fn param_size_boundary() {
563        // Exactly at the limit should pass
564        let config = TRonConfig {
565            max_param_size_bytes: 2, // Tiny limit: "{}" is 2 bytes
566            default_unknown_agent: DefaultAction::Allow,
567            default_unknown_tool: DefaultAction::Allow,
568            scan_payloads: false,
569            analyze_patterns: false,
570        };
571        let tron = TRon::new(config);
572        let call = gate::ToolCall {
573            agent_id: "agent".to_string(),
574            tool_name: "tool".to_string(),
575            params: serde_json::json!({}), // serializes to "{}" = 2 bytes
576            timestamp: chrono::Utc::now(),
577        };
578        let verdict = tron.check(&call).await;
579        assert!(verdict.is_allowed());
580
581        // One byte over should deny
582        let call_over = gate::ToolCall {
583            agent_id: "agent".to_string(),
584            tool_name: "tool".to_string(),
585            params: serde_json::json!({"a":1}), // serializes to {"a":1} = 7 bytes
586            timestamp: chrono::Utc::now(),
587        };
588        let verdict = tron.check(&call_over).await;
589        assert!(matches!(
590            verdict,
591            gate::Verdict::Deny {
592                code: gate::DenyCode::ParameterTooLarge,
593                ..
594            }
595        ));
596    }
597
598    #[tokio::test]
599    async fn audit_logged_for_every_verdict() {
600        let config = TRonConfig {
601            default_unknown_agent: DefaultAction::Allow,
602            default_unknown_tool: DefaultAction::Allow,
603            scan_payloads: false,
604            analyze_patterns: false,
605            ..Default::default()
606        };
607        let tron = TRon::new(config);
608        let call = gate::ToolCall {
609            agent_id: "agent".to_string(),
610            tool_name: "tool".to_string(),
611            params: serde_json::json!({}),
612            timestamp: chrono::Utc::now(),
613        };
614        tron.check(&call).await;
615        tron.check(&call).await;
616
617        let query = tron.query();
618        assert_eq!(query.total_events().await, 2);
619    }
620
621    #[tokio::test]
622    async fn rate_limit_from_policy() {
623        let config = TRonConfig {
624            default_unknown_agent: DefaultAction::Allow,
625            default_unknown_tool: DefaultAction::Allow,
626            scan_payloads: false,
627            analyze_patterns: false,
628            ..Default::default()
629        };
630        let tron = TRon::new(config);
631        tron.load_policy(
632            r#"
633[agent."limited"]
634allow = ["*"]
635[agent."limited".rate_limit]
636calls_per_minute = 5
637"#,
638        )
639        .unwrap();
640
641        let call = gate::ToolCall {
642            agent_id: "limited".to_string(),
643            tool_name: "tool".to_string(),
644            params: serde_json::json!({}),
645            timestamp: chrono::Utc::now(),
646        };
647        for _ in 0..5 {
648            assert!(tron.check(&call).await.is_allowed());
649        }
650        // 6th call should be rate limited
651        assert!(matches!(
652            tron.check(&call).await,
653            gate::Verdict::Deny {
654                code: gate::DenyCode::RateLimited,
655                ..
656            }
657        ));
658    }
659
660    #[tokio::test]
661    async fn load_policy_file_and_reload() {
662        let dir = tempfile::tempdir().unwrap();
663        let path = dir.path().join("t-ron.toml");
664        std::fs::write(
665            &path,
666            r#"
667[agent."file-agent"]
668allow = ["tarang_*"]
669"#,
670        )
671        .unwrap();
672
673        let tron = TRon::new(TRonConfig::default());
674        tron.load_policy_file(&path).unwrap();
675
676        let call = gate::ToolCall {
677            agent_id: "file-agent".to_string(),
678            tool_name: "tarang_probe".to_string(),
679            params: serde_json::json!({}),
680            timestamp: chrono::Utc::now(),
681        };
682        assert!(tron.check(&call).await.is_allowed());
683
684        // Update the file and reload
685        std::fs::write(
686            &path,
687            r#"
688[agent."file-agent"]
689allow = ["rasa_*"]
690deny = ["tarang_*"]
691"#,
692        )
693        .unwrap();
694
695        tron.reload_policy().unwrap();
696        // tarang_probe should now be denied
697        assert!(tron.check(&call).await.is_denied());
698    }
699
700    #[test]
701    fn reload_without_file_errors() {
702        let tron = TRon::new(TRonConfig::default());
703        assert!(tron.reload_policy().is_err());
704    }
705}