use super::behavioral_drift::{BehavioralDriftMonitor, Observation};
use super::config::{AirlockConfig, AirlockMode};
use super::exfiltration::ExfiltrationShield;
use super::patterns::RcePatternMatcher;
use super::schema_drift::SchemaDriftGuard;
use super::velocity::VelocityTracker;
use fd_core::{AgentId, RunId, ToolVersionId};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ViolationType {
RcePattern,
VelocityBreach,
LoopDetection,
ExfiltrationAttempt,
IpAddressUsed,
SchemaDrift,
CredentialLeak,
DataExfiltrationBudget,
BehavioralDrift,
CoherenceDivergence,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RiskLevel {
Low,
Medium,
High,
Critical,
}
impl RiskLevel {
pub fn from_score(score: u8) -> Self {
match score {
0..=39 => RiskLevel::Low,
40..=59 => RiskLevel::Medium,
60..=79 => RiskLevel::High,
_ => RiskLevel::Critical,
}
}
pub fn as_str(&self) -> &'static str {
match self {
RiskLevel::Low => "low",
RiskLevel::Medium => "medium",
RiskLevel::High => "high",
RiskLevel::Critical => "critical",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AirlockViolation {
pub violation_type: ViolationType,
pub risk_score: u8,
pub risk_level: RiskLevel,
pub details: String,
pub trigger: String,
}
#[derive(Debug, Clone)]
pub struct InspectionContext {
pub run_id: RunId,
pub tool_name: String,
pub tool_input: serde_json::Value,
pub estimated_cost_cents: Option<u64>,
pub tool_version_id: Option<ToolVersionId>,
pub agent_id: Option<AgentId>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AirlockResult {
pub allowed: bool,
pub violation: Option<AirlockViolation>,
pub shadow_mode: bool,
pub risk_score: u8,
pub risk_level: RiskLevel,
}
impl Default for AirlockResult {
fn default() -> Self {
Self {
allowed: true,
violation: None,
shadow_mode: false,
risk_score: 0,
risk_level: RiskLevel::Low,
}
}
}
pub struct AirlockInspector {
config: AirlockConfig,
rce_matcher: RcePatternMatcher,
velocity_tracker: Arc<VelocityTracker>,
exfiltration_shield: ExfiltrationShield,
schema_drift_guard: Option<Arc<SchemaDriftGuard>>,
behavioral_drift_monitor: Option<Arc<BehavioralDriftMonitor>>,
}
impl AirlockInspector {
pub fn new(config: AirlockConfig) -> Self {
let rce_matcher = RcePatternMatcher::new(&config.rce);
let velocity_tracker = Arc::new(VelocityTracker::new(config.velocity.clone()));
let exfiltration_shield = ExfiltrationShield::new(&config.exfiltration);
info!(
mode = ?config.mode,
rce_enabled = config.rce.enabled,
velocity_enabled = config.velocity.enabled,
exfil_enabled = config.exfiltration.enabled,
schema_drift_enabled = config.schema_drift.enabled,
"Airlock inspector initialized"
);
Self {
config,
rce_matcher,
velocity_tracker,
exfiltration_shield,
schema_drift_guard: None,
behavioral_drift_monitor: None,
}
}
pub fn with_schema_drift_guard(mut self, guard: Arc<SchemaDriftGuard>) -> Self {
info!(
schema_count = guard.len(),
"schema-drift guard attached to Airlock inspector"
);
self.schema_drift_guard = Some(guard);
self
}
pub fn with_behavioral_drift_monitor(mut self, monitor: Arc<BehavioralDriftMonitor>) -> Self {
info!("behavioral-drift monitor attached to Airlock inspector");
self.behavioral_drift_monitor = Some(monitor);
self
}
pub fn is_shadow_mode(&self) -> bool {
matches!(self.config.mode, AirlockMode::Shadow)
}
pub fn config(&self) -> &AirlockConfig {
&self.config
}
pub fn velocity_tracker(&self) -> Arc<VelocityTracker> {
Arc::clone(&self.velocity_tracker)
}
pub async fn inspect(&self, ctx: &InspectionContext) -> AirlockResult {
let shadow_mode = self.is_shadow_mode();
debug!(
run_id = %ctx.run_id,
tool = %ctx.tool_name,
shadow_mode = shadow_mode,
"Inspecting tool call"
);
if let (Some(monitor), Some(agent_id), Some(cost_cents)) = (
self.behavioral_drift_monitor.as_ref(),
ctx.agent_id,
ctx.estimated_cost_cents,
) {
if let Some(violation) = monitor
.observe(
agent_id,
Observation::with_cost(cost_cents),
&self.config.behavioral_drift,
)
.await
{
warn!(
run_id = %ctx.run_id,
agent_id = %agent_id,
tool = %ctx.tool_name,
violation_type = ?violation.violation_type,
risk_score = violation.risk_score,
shadow_mode = shadow_mode,
"Behavioral drift detected"
);
return AirlockResult {
allowed: shadow_mode,
violation: Some(violation.clone()),
shadow_mode,
risk_score: violation.risk_score,
risk_level: violation.risk_level,
};
}
}
if let (Some(guard), Some(tv_id)) = (
self.schema_drift_guard.as_ref(),
ctx.tool_version_id.as_ref(),
) {
if let Some(violation) = guard.check(tv_id, &ctx.tool_input, &self.config.schema_drift)
{
warn!(
run_id = %ctx.run_id,
tool = %ctx.tool_name,
tool_version_id = %tv_id,
violation_type = ?violation.violation_type,
risk_score = violation.risk_score,
shadow_mode = shadow_mode,
"Schema drift detected"
);
return AirlockResult {
allowed: shadow_mode,
violation: Some(violation.clone()),
shadow_mode,
risk_score: violation.risk_score,
risk_level: violation.risk_level,
};
}
}
if self.config.rce.enabled {
if let Some(violation) = self.rce_matcher.check(&ctx.tool_name, &ctx.tool_input) {
warn!(
run_id = %ctx.run_id,
tool = %ctx.tool_name,
violation_type = ?violation.violation_type,
risk_score = violation.risk_score,
trigger = %violation.trigger,
shadow_mode = shadow_mode,
"RCE pattern detected"
);
return AirlockResult {
allowed: shadow_mode, violation: Some(violation.clone()),
shadow_mode,
risk_score: violation.risk_score,
risk_level: violation.risk_level,
};
}
}
if self.config.velocity.enabled {
if let Some(violation) = self.velocity_tracker.check(ctx).await {
warn!(
run_id = %ctx.run_id,
tool = %ctx.tool_name,
violation_type = ?violation.violation_type,
risk_score = violation.risk_score,
shadow_mode = shadow_mode,
"Velocity violation detected"
);
return AirlockResult {
allowed: shadow_mode,
violation: Some(violation.clone()),
shadow_mode,
risk_score: violation.risk_score,
risk_level: violation.risk_level,
};
}
}
if self.config.exfiltration.enabled {
if let Some(violation) = self
.exfiltration_shield
.check(&ctx.tool_name, &ctx.tool_input)
{
warn!(
run_id = %ctx.run_id,
tool = %ctx.tool_name,
violation_type = ?violation.violation_type,
risk_score = violation.risk_score,
trigger = %violation.trigger,
shadow_mode = shadow_mode,
"Exfiltration attempt detected"
);
return AirlockResult {
allowed: shadow_mode,
violation: Some(violation.clone()),
shadow_mode,
risk_score: violation.risk_score,
risk_level: violation.risk_level,
};
}
let urls = super::exfiltration::ExfiltrationShield::extract_urls(&ctx.tool_input);
if !urls.is_empty() {
let bytes = super::exfiltration::ExfiltrationShield::estimate_payload_bytes(
&ctx.tool_input,
);
let run_id_str = ctx.run_id.to_string();
for url in &urls {
if let Some(domain) =
super::exfiltration::ExfiltrationShield::extract_domain(url)
{
if let Some(violation) = self
.exfiltration_shield
.check_and_record_egress(&run_id_str, &domain, bytes)
.await
{
warn!(
run_id = %ctx.run_id,
tool = %ctx.tool_name,
violation_type = ?violation.violation_type,
risk_score = violation.risk_score,
trigger = %violation.trigger,
shadow_mode = shadow_mode,
"Per-domain data budget exceeded"
);
return AirlockResult {
allowed: shadow_mode,
violation: Some(violation.clone()),
shadow_mode,
risk_score: violation.risk_score,
risk_level: violation.risk_level,
};
}
}
}
}
}
debug!(
run_id = %ctx.run_id,
tool = %ctx.tool_name,
"Tool call passed all Airlock checks"
);
AirlockResult::default()
}
pub async fn record_call(&self, ctx: &InspectionContext) {
if self.config.velocity.enabled {
self.velocity_tracker.record(ctx).await;
}
}
pub async fn clear_run(&self, run_id: &str) {
self.velocity_tracker.clear_run(run_id).await;
self.exfiltration_shield.clear_run(run_id).await;
}
pub async fn velocity_stats(&self) -> super::velocity::VelocityStats {
self.velocity_tracker.stats().await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::airlock::config::{ExfiltrationConfig, RceConfig, VelocityConfig};
fn create_test_config() -> AirlockConfig {
AirlockConfig {
mode: AirlockMode::Enforce,
rce: RceConfig::default(),
velocity: VelocityConfig::default(),
exfiltration: ExfiltrationConfig::default(),
schema_drift: crate::airlock::config::SchemaDriftConfig::default(),
behavioral_drift: crate::airlock::config::BehavioralDriftConfig::default(),
}
}
fn create_shadow_config() -> AirlockConfig {
AirlockConfig {
mode: AirlockMode::Shadow,
..create_test_config()
}
}
fn create_context(tool: &str, input: serde_json::Value) -> InspectionContext {
InspectionContext {
run_id: RunId::new(),
tool_name: tool.to_string(),
tool_input: input,
estimated_cost_cents: Some(10),
tool_version_id: None,
agent_id: None,
}
}
#[tokio::test]
async fn test_clean_tool_call() {
let inspector = AirlockInspector::new(create_test_config());
let ctx = create_context(
"read_file",
serde_json::json!({
"path": "/home/user/document.txt"
}),
);
let result = inspector.inspect(&ctx).await;
assert!(result.allowed);
assert!(result.violation.is_none());
assert_eq!(result.risk_score, 0);
}
#[tokio::test]
async fn test_rce_pattern_blocked_enforce() {
let inspector = AirlockInspector::new(create_test_config());
let ctx = create_context(
"write_file",
serde_json::json!({
"content": "result = eval(user_input)"
}),
);
let result = inspector.inspect(&ctx).await;
assert!(!result.allowed); assert!(result.violation.is_some());
assert_eq!(
result.violation.unwrap().violation_type,
ViolationType::RcePattern
);
}
#[tokio::test]
async fn test_rce_pattern_logged_shadow() {
let inspector = AirlockInspector::new(create_shadow_config());
let ctx = create_context(
"write_file",
serde_json::json!({
"content": "result = eval(user_input)"
}),
);
let result = inspector.inspect(&ctx).await;
assert!(result.allowed); assert!(result.shadow_mode);
assert!(result.violation.is_some()); }
#[tokio::test]
async fn test_exfiltration_blocked() {
let config = AirlockConfig {
mode: AirlockMode::Enforce,
rce: RceConfig::default(),
velocity: VelocityConfig::default(),
exfiltration: ExfiltrationConfig {
enabled: true,
target_tools: vec!["http_get".to_string()],
allowed_domains: vec!["allowed.com".to_string()],
block_ip_addresses: true,
credential_dlp_enabled: false,
data_budget_per_domain_bytes: None,
},
schema_drift: crate::airlock::config::SchemaDriftConfig::default(),
behavioral_drift: crate::airlock::config::BehavioralDriftConfig::default(),
};
let inspector = AirlockInspector::new(config);
let ctx = create_context(
"http_get",
serde_json::json!({
"url": "https://evil.com/steal"
}),
);
let result = inspector.inspect(&ctx).await;
assert!(!result.allowed);
assert!(result.violation.is_some());
assert_eq!(
result.violation.unwrap().violation_type,
ViolationType::ExfiltrationAttempt
);
}
#[tokio::test]
async fn test_ip_address_blocked() {
let config = AirlockConfig {
mode: AirlockMode::Enforce,
rce: RceConfig::default(),
velocity: VelocityConfig::default(),
exfiltration: ExfiltrationConfig {
enabled: true,
target_tools: vec!["http_get".to_string()],
allowed_domains: vec![], block_ip_addresses: true,
credential_dlp_enabled: false,
data_budget_per_domain_bytes: None,
},
schema_drift: crate::airlock::config::SchemaDriftConfig::default(),
behavioral_drift: crate::airlock::config::BehavioralDriftConfig::default(),
};
let inspector = AirlockInspector::new(config);
let ctx = create_context(
"http_get",
serde_json::json!({
"url": "http://192.168.1.100:8080/api"
}),
);
let result = inspector.inspect(&ctx).await;
assert!(!result.allowed);
assert!(result.violation.is_some());
assert_eq!(
result.violation.unwrap().violation_type,
ViolationType::IpAddressUsed
);
}
#[tokio::test]
async fn test_velocity_loop_detection() {
let config = AirlockConfig {
mode: AirlockMode::Enforce,
rce: RceConfig::default(),
velocity: VelocityConfig {
enabled: true,
max_cost_cents: 1000,
window_seconds: 60,
loop_threshold: 3,
},
exfiltration: ExfiltrationConfig::default(),
schema_drift: crate::airlock::config::SchemaDriftConfig::default(),
behavioral_drift: crate::airlock::config::BehavioralDriftConfig::default(),
};
let inspector = AirlockInspector::new(config);
let ctx = create_context(
"some_tool",
serde_json::json!({
"same": "input"
}),
);
for _ in 0..3 {
inspector.record_call(&ctx).await;
}
let result = inspector.inspect(&ctx).await;
assert!(!result.allowed);
assert!(result.violation.is_some());
assert_eq!(
result.violation.unwrap().violation_type,
ViolationType::LoopDetection
);
}
#[tokio::test]
async fn test_risk_level_from_score() {
assert_eq!(RiskLevel::from_score(0), RiskLevel::Low);
assert_eq!(RiskLevel::from_score(39), RiskLevel::Low);
assert_eq!(RiskLevel::from_score(40), RiskLevel::Medium);
assert_eq!(RiskLevel::from_score(59), RiskLevel::Medium);
assert_eq!(RiskLevel::from_score(60), RiskLevel::High);
assert_eq!(RiskLevel::from_score(79), RiskLevel::High);
assert_eq!(RiskLevel::from_score(80), RiskLevel::Critical);
assert_eq!(RiskLevel::from_score(100), RiskLevel::Critical);
}
#[tokio::test]
async fn test_clear_run() {
let inspector = AirlockInspector::new(create_test_config());
let run_id = RunId::new();
let ctx = InspectionContext {
run_id,
tool_name: "tool".to_string(),
tool_input: serde_json::json!({}),
estimated_cost_cents: Some(10),
tool_version_id: None,
agent_id: None,
};
inspector.record_call(&ctx).await;
inspector.record_call(&ctx).await;
let stats = inspector.velocity_stats().await;
assert_eq!(stats.tracked_runs, 1);
inspector.clear_run(&run_id.to_string()).await;
let stats = inspector.velocity_stats().await;
assert_eq!(stats.tracked_runs, 0);
}
}