pub mod audit;
pub mod correlation;
pub mod gate;
pub mod middleware;
pub mod pattern;
pub mod policy;
pub mod query;
pub mod rate;
pub mod scanner;
pub mod score;
pub mod tools;
pub mod safety;
#[cfg(all(unix, feature = "signal"))]
pub mod signal;
#[cfg(feature = "signing")]
pub mod signing;
#[cfg(feature = "llm")]
pub mod llm_scan;
mod error;
pub use error::TRonError;
use std::path::PathBuf;
use std::sync::Arc;
pub struct TRon {
policy: Arc<policy::PolicyEngine>,
rate_limiter: Arc<rate::RateLimiter>,
pattern: Arc<pattern::PatternAnalyzer>,
correlation: Arc<correlation::CorrelationDetector>,
audit: Arc<audit::AuditLogger>,
config: TRonConfig,
policy_path: std::sync::Mutex<Option<PathBuf>>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TRonConfig {
pub default_unknown_agent: DefaultAction,
pub default_unknown_tool: DefaultAction,
pub max_param_size_bytes: usize,
pub scan_payloads: bool,
pub analyze_patterns: bool,
pub enable_correlation: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub enum DefaultAction {
Allow,
Deny,
Flag,
}
impl Default for TRonConfig {
fn default() -> Self {
Self {
default_unknown_agent: DefaultAction::Deny,
default_unknown_tool: DefaultAction::Deny,
max_param_size_bytes: 65536,
scan_payloads: true,
analyze_patterns: true,
enable_correlation: false,
}
}
}
impl TRon {
#[must_use]
pub fn new(config: TRonConfig) -> Self {
Self {
policy: Arc::new(policy::PolicyEngine::new()),
rate_limiter: Arc::new(rate::RateLimiter::new()),
pattern: Arc::new(pattern::PatternAnalyzer::new()),
correlation: Arc::new(correlation::CorrelationDetector::default()),
audit: Arc::new(audit::AuditLogger::new()),
config,
policy_path: std::sync::Mutex::new(None),
}
}
pub async fn check(&self, call: &gate::ToolCall) -> gate::Verdict {
let param_size = {
let mut counter = ByteCounter(0);
let _ = serde_json::to_writer(&mut counter, &call.params);
counter.0
};
if param_size > self.config.max_param_size_bytes {
let verdict = gate::Verdict::Deny {
reason: format!(
"parameter size {} exceeds limit {}",
param_size, self.config.max_param_size_bytes
),
code: gate::DenyCode::ParameterTooLarge,
};
self.audit.log(call, &verdict).await;
return verdict;
}
match self.policy.check(&call.agent_id, &call.tool_name) {
policy::PolicyResult::Allow => {}
policy::PolicyResult::Deny(reason) => {
let verdict = gate::Verdict::Deny {
reason,
code: gate::DenyCode::Unauthorized,
};
self.audit.log(call, &verdict).await;
return verdict;
}
policy::PolicyResult::UnknownAgent => {
if let Some(v) = default_action_verdict(
self.config.default_unknown_agent,
"unknown agent".to_string(),
) {
self.audit.log(call, &v).await;
return v;
}
}
policy::PolicyResult::UnknownTool => {
if let Some(v) = default_action_verdict(
self.config.default_unknown_tool,
format!(
"tool '{}' not in policy for agent '{}'",
call.tool_name, call.agent_id
),
) {
self.audit.log(call, &v).await;
return v;
}
}
}
if !self.rate_limiter.check(&call.agent_id, &call.tool_name) {
let verdict = gate::Verdict::Deny {
reason: "rate limit exceeded".to_string(),
code: gate::DenyCode::RateLimited,
};
self.audit.log(call, &verdict).await;
return verdict;
}
if self.config.scan_payloads
&& let Some(threat) = scanner::scan(&call.params)
{
let verdict = gate::Verdict::Deny {
reason: format!("injection detected: {threat}"),
code: gate::DenyCode::InjectionDetected,
};
self.audit.log(call, &verdict).await;
return verdict;
}
if self.config.analyze_patterns {
self.pattern.record(call);
if let Some(anomaly) = self.pattern.check_anomaly(&call.agent_id) {
let verdict = gate::Verdict::Flag {
reason: format!("anomalous pattern: {anomaly}"),
};
self.audit.log(call, &verdict).await;
return verdict;
}
}
if self.config.enable_correlation
&& let Some(alert) =
self.correlation
.record_and_check(&call.agent_id, &call.tool_name, call.timestamp)
{
let verdict = gate::Verdict::Flag {
reason: format!("correlation alert: {alert}"),
};
self.audit.log(call, &verdict).await;
return verdict;
}
let verdict = gate::Verdict::Allow;
self.audit.log(call, &verdict).await;
verdict
}
pub fn load_policy(&self, toml_str: &str) -> Result<(), TRonError> {
self.policy.load_toml(toml_str)?;
self.apply_rate_limits();
Ok(())
}
pub fn load_policy_file(&self, path: impl Into<PathBuf>) -> Result<(), TRonError> {
let path = path.into();
let content = std::fs::read_to_string(&path)?;
self.load_policy(&content)?;
*self.policy_path.lock().unwrap_or_else(|p| p.into_inner()) = Some(path);
Ok(())
}
pub fn reload_policy(&self) -> Result<(), TRonError> {
let path = self
.policy_path
.lock()
.unwrap_or_else(|p| p.into_inner())
.clone();
match path {
Some(p) => {
tracing::info!(path = %p.display(), "reloading policy from file");
let content = std::fs::read_to_string(&p)?;
self.load_policy(&content)
}
None => Err(TRonError::Policy(
"no policy file path set — use load_policy_file first".into(),
)),
}
}
fn apply_rate_limits(&self) {
let config = self.policy.config();
for (agent_id, agent_policy) in &config.agent {
if let Some(ref rl) = agent_policy.rate_limit {
tracing::debug!(
agent = agent_id,
cpm = rl.calls_per_minute,
"applying rate limit from policy"
);
self.rate_limiter.set_rate(agent_id, rl.calls_per_minute);
}
}
}
#[cfg(feature = "signing")]
pub fn verify_and_load_policy(
&self,
path: impl Into<PathBuf>,
verifier: &signing::PolicyVerifier,
) -> Result<(), TRonError> {
let path = path.into();
let content = verifier.verify_and_read(&path)?;
self.load_policy(&content)?;
*self.policy_path.lock().unwrap_or_else(|p| p.into_inner()) = Some(path);
Ok(())
}
pub fn discover_and_load_policy(&self) -> Result<PathBuf, TRonError> {
let mut tried = Vec::new();
#[cfg(unix)]
{
let sys = PathBuf::from("/etc/agnos/t-ron.toml");
if sys.is_file() {
return self.load_policy_file(&sys).map(|()| sys);
}
tried.push(sys);
}
let xdg = std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|_| std::env::var("HOME").map(|h| PathBuf::from(h).join(".config")));
if let Ok(config_dir) = xdg {
let user = config_dir.join("t-ron").join("t-ron.toml");
if user.is_file() {
return self.load_policy_file(&user).map(|()| user);
}
tried.push(user);
}
let local = PathBuf::from("t-ron.toml");
if local.is_file() {
return self.load_policy_file(&local).map(|()| local);
}
tried.push(local);
let paths: Vec<String> = tried.iter().map(|p| p.display().to_string()).collect();
Err(TRonError::Policy(format!(
"no policy file found in standard paths: {}",
paths.join(", ")
)))
}
#[must_use]
pub fn query(&self) -> query::TRonQuery {
query::TRonQuery {
audit: self.audit.clone(),
}
}
#[must_use]
pub fn policy_arc(&self) -> Arc<policy::PolicyEngine> {
self.policy.clone()
}
}
struct ByteCounter(usize);
impl std::io::Write for ByteCounter {
#[inline]
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0 += buf.len();
Ok(buf.len())
}
#[inline]
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn default_action_verdict(action: DefaultAction, reason: String) -> Option<gate::Verdict> {
match action {
DefaultAction::Deny => Some(gate::Verdict::Deny {
reason,
code: gate::DenyCode::Unauthorized,
}),
DefaultAction::Flag => Some(gate::Verdict::Flag { reason }),
DefaultAction::Allow => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config() {
let config = TRonConfig::default();
assert_eq!(config.default_unknown_agent, DefaultAction::Deny);
assert_eq!(config.max_param_size_bytes, 65536);
assert!(config.scan_payloads);
}
#[tokio::test]
async fn deny_unknown_agent() {
let tron = TRon::new(TRonConfig::default());
let call = gate::ToolCall {
agent_id: "unknown-agent".to_string(),
tool_name: "some_tool".to_string(),
params: serde_json::json!({}),
timestamp: chrono::Utc::now(),
};
let verdict = tron.check(&call).await;
assert!(matches!(verdict, gate::Verdict::Deny { .. }));
}
#[tokio::test]
async fn deny_oversized_params() {
let config = TRonConfig {
max_param_size_bytes: 10,
default_unknown_agent: DefaultAction::Allow,
..Default::default()
};
let tron = TRon::new(config);
let call = gate::ToolCall {
agent_id: "agent".to_string(),
tool_name: "tool".to_string(),
params: serde_json::json!({"data": "this is way more than 10 bytes of parameter data"}),
timestamp: chrono::Utc::now(),
};
let verdict = tron.check(&call).await;
assert!(matches!(
verdict,
gate::Verdict::Deny {
code: gate::DenyCode::ParameterTooLarge,
..
}
));
}
#[tokio::test]
async fn allow_known_agent_known_tool() {
let tron = TRon::new(TRonConfig::default());
tron.load_policy(
r#"
[agent."web-agent"]
allow = ["tarang_*"]
"#,
)
.unwrap();
let call = gate::ToolCall {
agent_id: "web-agent".to_string(),
tool_name: "tarang_probe".to_string(),
params: serde_json::json!({"path": "/test"}),
timestamp: chrono::Utc::now(),
};
let verdict = tron.check(&call).await;
assert!(verdict.is_allowed());
assert!(!verdict.is_denied());
}
#[tokio::test]
async fn flag_unknown_agent() {
let config = TRonConfig {
default_unknown_agent: DefaultAction::Flag,
..Default::default()
};
let tron = TRon::new(config);
let call = gate::ToolCall {
agent_id: "mystery".to_string(),
tool_name: "tool".to_string(),
params: serde_json::json!({}),
timestamp: chrono::Utc::now(),
};
let verdict = tron.check(&call).await;
assert!(matches!(verdict, gate::Verdict::Flag { .. }));
assert!(verdict.is_allowed()); }
#[tokio::test]
async fn deny_unknown_tool_for_known_agent() {
let tron = TRon::new(TRonConfig::default());
tron.load_policy(
r#"
[agent."limited"]
allow = ["tarang_*"]
"#,
)
.unwrap();
let call = gate::ToolCall {
agent_id: "limited".to_string(),
tool_name: "aegis_scan".to_string(), params: serde_json::json!({}),
timestamp: chrono::Utc::now(),
};
let verdict = tron.check(&call).await;
assert!(verdict.is_denied());
}
#[tokio::test]
async fn flag_unknown_tool() {
let config = TRonConfig {
default_unknown_tool: DefaultAction::Flag,
..Default::default()
};
let tron = TRon::new(config);
tron.load_policy(
r#"
[agent."agent-1"]
allow = ["tarang_*"]
"#,
)
.unwrap();
let call = gate::ToolCall {
agent_id: "agent-1".to_string(),
tool_name: "rasa_edit".to_string(),
params: serde_json::json!({}),
timestamp: chrono::Utc::now(),
};
let verdict = tron.check(&call).await;
assert!(matches!(verdict, gate::Verdict::Flag { .. }));
}
#[tokio::test]
async fn allow_unknown_agent_passthrough() {
let config = TRonConfig {
default_unknown_agent: DefaultAction::Allow,
default_unknown_tool: DefaultAction::Allow,
..Default::default()
};
let tron = TRon::new(config);
let call = gate::ToolCall {
agent_id: "whoever".to_string(),
tool_name: "whatever".to_string(),
params: serde_json::json!({"safe": true}),
timestamp: chrono::Utc::now(),
};
let verdict = tron.check(&call).await;
assert!(verdict.is_allowed());
}
#[tokio::test]
async fn deny_injection_through_pipeline() {
let config = TRonConfig {
default_unknown_agent: DefaultAction::Allow,
default_unknown_tool: DefaultAction::Allow,
..Default::default()
};
let tron = TRon::new(config);
let call = gate::ToolCall {
agent_id: "agent".to_string(),
tool_name: "tool".to_string(),
params: serde_json::json!({"q": "1 UNION SELECT * FROM passwords"}),
timestamp: chrono::Utc::now(),
};
let verdict = tron.check(&call).await;
assert!(matches!(
verdict,
gate::Verdict::Deny {
code: gate::DenyCode::InjectionDetected,
..
}
));
}
#[tokio::test]
async fn scan_payloads_disabled_bypass() {
let config = TRonConfig {
default_unknown_agent: DefaultAction::Allow,
default_unknown_tool: DefaultAction::Allow,
scan_payloads: false,
..Default::default()
};
let tron = TRon::new(config);
let call = gate::ToolCall {
agent_id: "agent".to_string(),
tool_name: "tool".to_string(),
params: serde_json::json!({"q": "1 UNION SELECT * FROM passwords"}),
timestamp: chrono::Utc::now(),
};
let verdict = tron.check(&call).await;
assert!(verdict.is_allowed());
}
#[tokio::test]
async fn analyze_patterns_disabled_bypass() {
let config = TRonConfig {
default_unknown_agent: DefaultAction::Allow,
default_unknown_tool: DefaultAction::Allow,
analyze_patterns: false,
..Default::default()
};
let tron = TRon::new(config);
for i in 0..20 {
let call = gate::ToolCall {
agent_id: "agent".to_string(),
tool_name: format!("tool_{i}"),
params: serde_json::json!({}),
timestamp: chrono::Utc::now(),
};
let verdict = tron.check(&call).await;
assert!(verdict.is_allowed());
}
}
#[tokio::test]
async fn rate_limit_through_pipeline() {
let config = TRonConfig {
default_unknown_agent: DefaultAction::Allow,
default_unknown_tool: DefaultAction::Allow,
scan_payloads: false,
analyze_patterns: false,
..Default::default()
};
let tron = TRon::new(config);
let call = gate::ToolCall {
agent_id: "agent".to_string(),
tool_name: "tool".to_string(),
params: serde_json::json!({}),
timestamp: chrono::Utc::now(),
};
for _ in 0..60 {
let v = tron.check(&call).await;
assert!(v.is_allowed());
}
let v = tron.check(&call).await;
assert!(matches!(
v,
gate::Verdict::Deny {
code: gate::DenyCode::RateLimited,
..
}
));
}
#[tokio::test]
async fn policy_deny_through_pipeline() {
let tron = TRon::new(TRonConfig::default());
tron.load_policy(
r#"
[agent."restricted"]
allow = ["tarang_*"]
deny = ["tarang_delete"]
"#,
)
.unwrap();
let call = gate::ToolCall {
agent_id: "restricted".to_string(),
tool_name: "tarang_delete".to_string(),
params: serde_json::json!({}),
timestamp: chrono::Utc::now(),
};
let verdict = tron.check(&call).await;
assert!(verdict.is_denied());
}
#[tokio::test]
async fn load_policy_error() {
let tron = TRon::new(TRonConfig::default());
assert!(tron.load_policy("not valid toml {{{").is_err());
}
#[tokio::test]
async fn param_size_boundary() {
let config = TRonConfig {
max_param_size_bytes: 2, default_unknown_agent: DefaultAction::Allow,
default_unknown_tool: DefaultAction::Allow,
scan_payloads: false,
analyze_patterns: false,
enable_correlation: false,
};
let tron = TRon::new(config);
let call = gate::ToolCall {
agent_id: "agent".to_string(),
tool_name: "tool".to_string(),
params: serde_json::json!({}), timestamp: chrono::Utc::now(),
};
let verdict = tron.check(&call).await;
assert!(verdict.is_allowed());
let call_over = gate::ToolCall {
agent_id: "agent".to_string(),
tool_name: "tool".to_string(),
params: serde_json::json!({"a":1}), timestamp: chrono::Utc::now(),
};
let verdict = tron.check(&call_over).await;
assert!(matches!(
verdict,
gate::Verdict::Deny {
code: gate::DenyCode::ParameterTooLarge,
..
}
));
}
#[tokio::test]
async fn audit_logged_for_every_verdict() {
let config = TRonConfig {
default_unknown_agent: DefaultAction::Allow,
default_unknown_tool: DefaultAction::Allow,
scan_payloads: false,
analyze_patterns: false,
..Default::default()
};
let tron = TRon::new(config);
let call = gate::ToolCall {
agent_id: "agent".to_string(),
tool_name: "tool".to_string(),
params: serde_json::json!({}),
timestamp: chrono::Utc::now(),
};
tron.check(&call).await;
tron.check(&call).await;
let query = tron.query();
assert_eq!(query.total_events().await, 2);
}
#[tokio::test]
async fn rate_limit_from_policy() {
let config = TRonConfig {
default_unknown_agent: DefaultAction::Allow,
default_unknown_tool: DefaultAction::Allow,
scan_payloads: false,
analyze_patterns: false,
..Default::default()
};
let tron = TRon::new(config);
tron.load_policy(
r#"
[agent."limited"]
allow = ["*"]
[agent."limited".rate_limit]
calls_per_minute = 5
"#,
)
.unwrap();
let call = gate::ToolCall {
agent_id: "limited".to_string(),
tool_name: "tool".to_string(),
params: serde_json::json!({}),
timestamp: chrono::Utc::now(),
};
for _ in 0..5 {
assert!(tron.check(&call).await.is_allowed());
}
assert!(matches!(
tron.check(&call).await,
gate::Verdict::Deny {
code: gate::DenyCode::RateLimited,
..
}
));
}
#[tokio::test]
async fn load_policy_file_and_reload() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t-ron.toml");
std::fs::write(
&path,
r#"
[agent."file-agent"]
allow = ["tarang_*"]
"#,
)
.unwrap();
let tron = TRon::new(TRonConfig::default());
tron.load_policy_file(&path).unwrap();
let call = gate::ToolCall {
agent_id: "file-agent".to_string(),
tool_name: "tarang_probe".to_string(),
params: serde_json::json!({}),
timestamp: chrono::Utc::now(),
};
assert!(tron.check(&call).await.is_allowed());
std::fs::write(
&path,
r#"
[agent."file-agent"]
allow = ["rasa_*"]
deny = ["tarang_*"]
"#,
)
.unwrap();
tron.reload_policy().unwrap();
assert!(tron.check(&call).await.is_denied());
}
#[test]
fn reload_without_file_errors() {
let tron = TRon::new(TRonConfig::default());
assert!(tron.reload_policy().is_err());
}
#[tokio::test]
async fn discover_policy_xdg_path() {
let dir = tempfile::tempdir().unwrap();
let config_dir = dir.path().join("t-ron");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("t-ron.toml"),
r#"
[agent."discovered"]
allow = ["tarang_*"]
"#,
)
.unwrap();
unsafe { std::env::set_var("XDG_CONFIG_HOME", dir.path()) };
let tron = TRon::new(TRonConfig::default());
let result = tron.discover_and_load_policy();
assert!(result.is_ok());
let call = gate::ToolCall {
agent_id: "discovered".to_string(),
tool_name: "tarang_probe".to_string(),
params: serde_json::json!({}),
timestamp: chrono::Utc::now(),
};
assert!(tron.check(&call).await.is_allowed());
unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
}
}