1use super::behavioral_drift::{BehavioralDriftMonitor, Observation};
11use super::config::{AirlockConfig, AirlockMode};
12use super::exfiltration::ExfiltrationShield;
13use super::patterns::RcePatternMatcher;
14use super::schema_drift::SchemaDriftGuard;
15use super::velocity::VelocityTracker;
16use fd_core::{AgentId, RunId, ToolVersionId};
17use serde::{Deserialize, Serialize};
18use std::sync::Arc;
19use tracing::{debug, info, warn};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum ViolationType {
25 RcePattern,
27 VelocityBreach,
29 LoopDetection,
31 ExfiltrationAttempt,
33 IpAddressUsed,
35 SchemaDrift,
37 CredentialLeak,
40 DataExfiltrationBudget,
43 BehavioralDrift,
45 CoherenceDivergence,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum RiskLevel {
55 Low,
57 Medium,
59 High,
61 Critical,
63}
64
65impl RiskLevel {
66 pub fn from_score(score: u8) -> Self {
68 match score {
69 0..=39 => RiskLevel::Low,
70 40..=59 => RiskLevel::Medium,
71 60..=79 => RiskLevel::High,
72 _ => RiskLevel::Critical,
73 }
74 }
75
76 pub fn as_str(&self) -> &'static str {
78 match self {
79 RiskLevel::Low => "low",
80 RiskLevel::Medium => "medium",
81 RiskLevel::High => "high",
82 RiskLevel::Critical => "critical",
83 }
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct AirlockViolation {
90 pub violation_type: ViolationType,
92 pub risk_score: u8,
94 pub risk_level: RiskLevel,
96 pub details: String,
98 pub trigger: String,
100}
101
102#[derive(Debug, Clone)]
104pub struct InspectionContext {
105 pub run_id: RunId,
107 pub tool_name: String,
109 pub tool_input: serde_json::Value,
111 pub estimated_cost_cents: Option<u64>,
113 pub tool_version_id: Option<ToolVersionId>,
117 pub agent_id: Option<AgentId>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct AirlockResult {
126 pub allowed: bool,
128 pub violation: Option<AirlockViolation>,
130 pub shadow_mode: bool,
132 pub risk_score: u8,
134 pub risk_level: RiskLevel,
136}
137
138impl Default for AirlockResult {
139 fn default() -> Self {
140 Self {
141 allowed: true,
142 violation: None,
143 shadow_mode: false,
144 risk_score: 0,
145 risk_level: RiskLevel::Low,
146 }
147 }
148}
149
150pub struct AirlockInspector {
155 config: AirlockConfig,
157 rce_matcher: RcePatternMatcher,
159 velocity_tracker: Arc<VelocityTracker>,
161 exfiltration_shield: ExfiltrationShield,
163 schema_drift_guard: Option<Arc<SchemaDriftGuard>>,
165 behavioral_drift_monitor: Option<Arc<BehavioralDriftMonitor>>,
167}
168
169impl AirlockInspector {
170 pub fn new(config: AirlockConfig) -> Self {
174 let rce_matcher = RcePatternMatcher::new(&config.rce);
175 let velocity_tracker = Arc::new(VelocityTracker::new(config.velocity.clone()));
176 let exfiltration_shield = ExfiltrationShield::new(&config.exfiltration);
177
178 info!(
179 mode = ?config.mode,
180 rce_enabled = config.rce.enabled,
181 velocity_enabled = config.velocity.enabled,
182 exfil_enabled = config.exfiltration.enabled,
183 schema_drift_enabled = config.schema_drift.enabled,
184 "Airlock inspector initialized"
185 );
186
187 Self {
188 config,
189 rce_matcher,
190 velocity_tracker,
191 exfiltration_shield,
192 schema_drift_guard: None,
193 behavioral_drift_monitor: None,
194 }
195 }
196
197 pub fn with_schema_drift_guard(mut self, guard: Arc<SchemaDriftGuard>) -> Self {
200 info!(
201 schema_count = guard.len(),
202 "schema-drift guard attached to Airlock inspector"
203 );
204 self.schema_drift_guard = Some(guard);
205 self
206 }
207
208 pub fn with_behavioral_drift_monitor(mut self, monitor: Arc<BehavioralDriftMonitor>) -> Self {
213 info!("behavioral-drift monitor attached to Airlock inspector");
214 self.behavioral_drift_monitor = Some(monitor);
215 self
216 }
217
218 pub fn is_shadow_mode(&self) -> bool {
220 matches!(self.config.mode, AirlockMode::Shadow)
221 }
222
223 pub fn config(&self) -> &AirlockConfig {
225 &self.config
226 }
227
228 pub fn velocity_tracker(&self) -> Arc<VelocityTracker> {
230 Arc::clone(&self.velocity_tracker)
231 }
232
233 pub async fn inspect(&self, ctx: &InspectionContext) -> AirlockResult {
238 let shadow_mode = self.is_shadow_mode();
239
240 debug!(
241 run_id = %ctx.run_id,
242 tool = %ctx.tool_name,
243 shadow_mode = shadow_mode,
244 "Inspecting tool call"
245 );
246
247 if let (Some(monitor), Some(agent_id), Some(cost_cents)) = (
250 self.behavioral_drift_monitor.as_ref(),
251 ctx.agent_id,
252 ctx.estimated_cost_cents,
253 ) {
254 if let Some(violation) = monitor
255 .observe(
256 agent_id,
257 Observation::with_cost(cost_cents),
258 &self.config.behavioral_drift,
259 )
260 .await
261 {
262 warn!(
263 run_id = %ctx.run_id,
264 agent_id = %agent_id,
265 tool = %ctx.tool_name,
266 violation_type = ?violation.violation_type,
267 risk_score = violation.risk_score,
268 shadow_mode = shadow_mode,
269 "Behavioral drift detected"
270 );
271
272 return AirlockResult {
273 allowed: shadow_mode,
274 violation: Some(violation.clone()),
275 shadow_mode,
276 risk_score: violation.risk_score,
277 risk_level: violation.risk_level,
278 };
279 }
280 }
281
282 if let (Some(guard), Some(tv_id)) = (
284 self.schema_drift_guard.as_ref(),
285 ctx.tool_version_id.as_ref(),
286 ) {
287 if let Some(violation) = guard.check(tv_id, &ctx.tool_input, &self.config.schema_drift)
288 {
289 warn!(
290 run_id = %ctx.run_id,
291 tool = %ctx.tool_name,
292 tool_version_id = %tv_id,
293 violation_type = ?violation.violation_type,
294 risk_score = violation.risk_score,
295 shadow_mode = shadow_mode,
296 "Schema drift detected"
297 );
298
299 return AirlockResult {
300 allowed: shadow_mode,
301 violation: Some(violation.clone()),
302 shadow_mode,
303 risk_score: violation.risk_score,
304 risk_level: violation.risk_level,
305 };
306 }
307 }
308
309 if self.config.rce.enabled {
311 if let Some(violation) = self.rce_matcher.check(&ctx.tool_name, &ctx.tool_input) {
312 warn!(
313 run_id = %ctx.run_id,
314 tool = %ctx.tool_name,
315 violation_type = ?violation.violation_type,
316 risk_score = violation.risk_score,
317 trigger = %violation.trigger,
318 shadow_mode = shadow_mode,
319 "RCE pattern detected"
320 );
321
322 return AirlockResult {
323 allowed: shadow_mode, violation: Some(violation.clone()),
325 shadow_mode,
326 risk_score: violation.risk_score,
327 risk_level: violation.risk_level,
328 };
329 }
330 }
331
332 if self.config.velocity.enabled {
334 if let Some(violation) = self.velocity_tracker.check(ctx).await {
335 warn!(
336 run_id = %ctx.run_id,
337 tool = %ctx.tool_name,
338 violation_type = ?violation.violation_type,
339 risk_score = violation.risk_score,
340 shadow_mode = shadow_mode,
341 "Velocity violation detected"
342 );
343
344 return AirlockResult {
345 allowed: shadow_mode,
346 violation: Some(violation.clone()),
347 shadow_mode,
348 risk_score: violation.risk_score,
349 risk_level: violation.risk_level,
350 };
351 }
352 }
353
354 if self.config.exfiltration.enabled {
357 if let Some(violation) = self
358 .exfiltration_shield
359 .check(&ctx.tool_name, &ctx.tool_input)
360 {
361 warn!(
362 run_id = %ctx.run_id,
363 tool = %ctx.tool_name,
364 violation_type = ?violation.violation_type,
365 risk_score = violation.risk_score,
366 trigger = %violation.trigger,
367 shadow_mode = shadow_mode,
368 "Exfiltration attempt detected"
369 );
370
371 return AirlockResult {
372 allowed: shadow_mode,
373 violation: Some(violation.clone()),
374 shadow_mode,
375 risk_score: violation.risk_score,
376 risk_level: violation.risk_level,
377 };
378 }
379
380 let urls = super::exfiltration::ExfiltrationShield::extract_urls(&ctx.tool_input);
386 if !urls.is_empty() {
387 let bytes = super::exfiltration::ExfiltrationShield::estimate_payload_bytes(
388 &ctx.tool_input,
389 );
390 let run_id_str = ctx.run_id.to_string();
391 for url in &urls {
392 if let Some(domain) =
393 super::exfiltration::ExfiltrationShield::extract_domain(url)
394 {
395 if let Some(violation) = self
396 .exfiltration_shield
397 .check_and_record_egress(&run_id_str, &domain, bytes)
398 .await
399 {
400 warn!(
401 run_id = %ctx.run_id,
402 tool = %ctx.tool_name,
403 violation_type = ?violation.violation_type,
404 risk_score = violation.risk_score,
405 trigger = %violation.trigger,
406 shadow_mode = shadow_mode,
407 "Per-domain data budget exceeded"
408 );
409 return AirlockResult {
410 allowed: shadow_mode,
411 violation: Some(violation.clone()),
412 shadow_mode,
413 risk_score: violation.risk_score,
414 risk_level: violation.risk_level,
415 };
416 }
417 }
418 }
419 }
420 }
421
422 debug!(
424 run_id = %ctx.run_id,
425 tool = %ctx.tool_name,
426 "Tool call passed all Airlock checks"
427 );
428
429 AirlockResult::default()
430 }
431
432 pub async fn record_call(&self, ctx: &InspectionContext) {
436 if self.config.velocity.enabled {
437 self.velocity_tracker.record(ctx).await;
438 }
439 }
440
441 pub async fn clear_run(&self, run_id: &str) {
445 self.velocity_tracker.clear_run(run_id).await;
446 self.exfiltration_shield.clear_run(run_id).await;
447 }
448
449 pub async fn velocity_stats(&self) -> super::velocity::VelocityStats {
451 self.velocity_tracker.stats().await
452 }
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458 use crate::airlock::config::{ExfiltrationConfig, RceConfig, VelocityConfig};
459
460 fn create_test_config() -> AirlockConfig {
461 AirlockConfig {
462 mode: AirlockMode::Enforce,
463 rce: RceConfig::default(),
464 velocity: VelocityConfig::default(),
465 exfiltration: ExfiltrationConfig::default(),
466 schema_drift: crate::airlock::config::SchemaDriftConfig::default(),
467 behavioral_drift: crate::airlock::config::BehavioralDriftConfig::default(),
468 }
469 }
470
471 fn create_shadow_config() -> AirlockConfig {
472 AirlockConfig {
473 mode: AirlockMode::Shadow,
474 ..create_test_config()
475 }
476 }
477
478 fn create_context(tool: &str, input: serde_json::Value) -> InspectionContext {
479 InspectionContext {
480 run_id: RunId::new(),
481 tool_name: tool.to_string(),
482 tool_input: input,
483 estimated_cost_cents: Some(10),
484 tool_version_id: None,
485 agent_id: None,
486 }
487 }
488
489 #[tokio::test]
490 async fn test_clean_tool_call() {
491 let inspector = AirlockInspector::new(create_test_config());
492
493 let ctx = create_context(
494 "read_file",
495 serde_json::json!({
496 "path": "/home/user/document.txt"
497 }),
498 );
499
500 let result = inspector.inspect(&ctx).await;
501 assert!(result.allowed);
502 assert!(result.violation.is_none());
503 assert_eq!(result.risk_score, 0);
504 }
505
506 #[tokio::test]
507 async fn test_rce_pattern_blocked_enforce() {
508 let inspector = AirlockInspector::new(create_test_config());
509
510 let ctx = create_context(
511 "write_file",
512 serde_json::json!({
513 "content": "result = eval(user_input)"
514 }),
515 );
516
517 let result = inspector.inspect(&ctx).await;
518 assert!(!result.allowed); assert!(result.violation.is_some());
520 assert_eq!(
521 result.violation.unwrap().violation_type,
522 ViolationType::RcePattern
523 );
524 }
525
526 #[tokio::test]
527 async fn test_rce_pattern_logged_shadow() {
528 let inspector = AirlockInspector::new(create_shadow_config());
529
530 let ctx = create_context(
531 "write_file",
532 serde_json::json!({
533 "content": "result = eval(user_input)"
534 }),
535 );
536
537 let result = inspector.inspect(&ctx).await;
538 assert!(result.allowed); assert!(result.shadow_mode);
540 assert!(result.violation.is_some()); }
542
543 #[tokio::test]
544 async fn test_exfiltration_blocked() {
545 let config = AirlockConfig {
546 mode: AirlockMode::Enforce,
547 rce: RceConfig::default(),
548 velocity: VelocityConfig::default(),
549 exfiltration: ExfiltrationConfig {
550 enabled: true,
551 target_tools: vec!["http_get".to_string()],
552 allowed_domains: vec!["allowed.com".to_string()],
553 block_ip_addresses: true,
554 credential_dlp_enabled: false,
555 data_budget_per_domain_bytes: None,
556 },
557 schema_drift: crate::airlock::config::SchemaDriftConfig::default(),
558 behavioral_drift: crate::airlock::config::BehavioralDriftConfig::default(),
559 };
560
561 let inspector = AirlockInspector::new(config);
562
563 let ctx = create_context(
564 "http_get",
565 serde_json::json!({
566 "url": "https://evil.com/steal"
567 }),
568 );
569
570 let result = inspector.inspect(&ctx).await;
571 assert!(!result.allowed);
572 assert!(result.violation.is_some());
573 assert_eq!(
574 result.violation.unwrap().violation_type,
575 ViolationType::ExfiltrationAttempt
576 );
577 }
578
579 #[tokio::test]
580 async fn test_ip_address_blocked() {
581 let config = AirlockConfig {
582 mode: AirlockMode::Enforce,
583 rce: RceConfig::default(),
584 velocity: VelocityConfig::default(),
585 exfiltration: ExfiltrationConfig {
586 enabled: true,
587 target_tools: vec!["http_get".to_string()],
588 allowed_domains: vec![], block_ip_addresses: true,
590 credential_dlp_enabled: false,
591 data_budget_per_domain_bytes: None,
592 },
593 schema_drift: crate::airlock::config::SchemaDriftConfig::default(),
594 behavioral_drift: crate::airlock::config::BehavioralDriftConfig::default(),
595 };
596
597 let inspector = AirlockInspector::new(config);
598
599 let ctx = create_context(
600 "http_get",
601 serde_json::json!({
602 "url": "http://192.168.1.100:8080/api"
603 }),
604 );
605
606 let result = inspector.inspect(&ctx).await;
607 assert!(!result.allowed);
608 assert!(result.violation.is_some());
609 assert_eq!(
610 result.violation.unwrap().violation_type,
611 ViolationType::IpAddressUsed
612 );
613 }
614
615 #[tokio::test]
616 async fn test_velocity_loop_detection() {
617 let config = AirlockConfig {
618 mode: AirlockMode::Enforce,
619 rce: RceConfig::default(),
620 velocity: VelocityConfig {
621 enabled: true,
622 max_cost_cents: 1000,
623 window_seconds: 60,
624 loop_threshold: 3,
625 },
626 exfiltration: ExfiltrationConfig::default(),
627 schema_drift: crate::airlock::config::SchemaDriftConfig::default(),
628 behavioral_drift: crate::airlock::config::BehavioralDriftConfig::default(),
629 };
630
631 let inspector = AirlockInspector::new(config);
632
633 let ctx = create_context(
634 "some_tool",
635 serde_json::json!({
636 "same": "input"
637 }),
638 );
639
640 for _ in 0..3 {
642 inspector.record_call(&ctx).await;
643 }
644
645 let result = inspector.inspect(&ctx).await;
647 assert!(!result.allowed);
648 assert!(result.violation.is_some());
649 assert_eq!(
650 result.violation.unwrap().violation_type,
651 ViolationType::LoopDetection
652 );
653 }
654
655 #[tokio::test]
656 async fn test_risk_level_from_score() {
657 assert_eq!(RiskLevel::from_score(0), RiskLevel::Low);
658 assert_eq!(RiskLevel::from_score(39), RiskLevel::Low);
659 assert_eq!(RiskLevel::from_score(40), RiskLevel::Medium);
660 assert_eq!(RiskLevel::from_score(59), RiskLevel::Medium);
661 assert_eq!(RiskLevel::from_score(60), RiskLevel::High);
662 assert_eq!(RiskLevel::from_score(79), RiskLevel::High);
663 assert_eq!(RiskLevel::from_score(80), RiskLevel::Critical);
664 assert_eq!(RiskLevel::from_score(100), RiskLevel::Critical);
665 }
666
667 #[tokio::test]
668 async fn test_clear_run() {
669 let inspector = AirlockInspector::new(create_test_config());
670 let run_id = RunId::new();
671
672 let ctx = InspectionContext {
673 run_id,
674 tool_name: "tool".to_string(),
675 tool_input: serde_json::json!({}),
676 estimated_cost_cents: Some(10),
677 tool_version_id: None,
678 agent_id: None,
679 };
680
681 inspector.record_call(&ctx).await;
683 inspector.record_call(&ctx).await;
684
685 let stats = inspector.velocity_stats().await;
686 assert_eq!(stats.tracked_runs, 1);
687
688 inspector.clear_run(&run_id.to_string()).await;
690
691 let stats = inspector.velocity_stats().await;
692 assert_eq!(stats.tracked_runs, 0);
693 }
694}