1pub 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
35pub 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 policy_path: std::sync::Mutex<Option<PathBuf>>,
44}
45
46#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
48pub struct TRonConfig {
49 pub default_unknown_agent: DefaultAction,
51 pub default_unknown_tool: DefaultAction,
53 pub max_param_size_bytes: usize,
55 pub scan_payloads: bool,
57 pub analyze_patterns: bool,
59}
60
61#[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 #[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 pub async fn check(&self, call: &gate::ToolCall) -> gate::Verdict {
98 let param_size = {
100 let mut counter = ByteCounter(0);
101 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 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 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 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 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 let verdict = gate::Verdict::Allow;
187 self.audit.log(call, &verdict).await;
188 verdict
189 }
190
191 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 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 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 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 #[must_use]
246 pub fn query(&self) -> query::TRonQuery {
247 query::TRonQuery {
248 audit: self.audit.clone(),
249 }
250 }
251
252 #[must_use]
254 pub fn policy_arc(&self) -> Arc<policy::PolicyEngine> {
255 self.policy.clone()
256 }
257}
258
259struct 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
275fn 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()); }
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(), 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 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 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 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 let config = TRonConfig {
565 max_param_size_bytes: 2, 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!({}), timestamp: chrono::Utc::now(),
577 };
578 let verdict = tron.check(&call).await;
579 assert!(verdict.is_allowed());
580
581 let call_over = gate::ToolCall {
583 agent_id: "agent".to_string(),
584 tool_name: "tool".to_string(),
585 params: serde_json::json!({"a":1}), 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 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 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 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}