Skip to main content

fd_policy/airlock/
inspector.rs

1//! Main Airlock Inspector
2//!
3//! Coordinates all inspection layers:
4//! 1. Anti-RCE pattern matching
5//! 2. Velocity/circuit breaker
6//! 3. Data exfiltration shield
7//!
8//! Returns combined result with risk scoring
9
10use 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/// Violation type categories
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum ViolationType {
25    /// Dangerous code pattern detected (eval, exec, etc.)
26    RcePattern,
27    /// Spending velocity exceeded
28    VelocityBreach,
29    /// Same tool+args called repeatedly (infinite loop)
30    LoopDetection,
31    /// Unauthorized network destination
32    ExfiltrationAttempt,
33    /// Raw IP address used instead of domain
34    IpAddressUsed,
35    /// Tool-call payload drifted from the registered input schema
36    SchemaDrift,
37    /// Outbound payload contains a high-confidence secret (cloud key,
38    /// PAT, Luhn-valid PAN, mod-97-valid IBAN, etc.)
39    CredentialLeak,
40    /// Cumulative bytes sent to a single domain within a run exceeded
41    /// the configured per-domain data budget
42    DataExfiltrationBudget,
43    /// Per-agent rolling-window z-score exceeded for some scalar dimension
44    BehavioralDrift,
45    /// Trajectory-level "strained coherence": the agent stated a blocking
46    /// fact that should change its plan, then took a contradicting closure
47    /// action as if the fact were untrue
48    CoherenceDivergence,
49}
50
51/// Risk level for violations
52#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum RiskLevel {
55    /// Risk score 0-39: Minor concern
56    Low,
57    /// Risk score 40-59: Moderate concern
58    Medium,
59    /// Risk score 60-79: Serious concern
60    High,
61    /// Risk score 80-100: Severe threat
62    Critical,
63}
64
65impl RiskLevel {
66    /// Convert risk score to level
67    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    /// Convert level to string for display
77    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/// A detected violation
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct AirlockViolation {
90    /// Type of violation
91    pub violation_type: ViolationType,
92    /// Risk score (0-100)
93    pub risk_score: u8,
94    /// Risk level derived from score
95    pub risk_level: RiskLevel,
96    /// Human-readable description
97    pub details: String,
98    /// Pattern or rule that triggered this
99    pub trigger: String,
100}
101
102/// Context for inspection
103#[derive(Debug, Clone)]
104pub struct InspectionContext {
105    /// Run ID for velocity tracking
106    pub run_id: RunId,
107    /// Tool being called
108    pub tool_name: String,
109    /// Tool input payload
110    pub tool_input: serde_json::Value,
111    /// Estimated cost for this tool call (in cents)
112    pub estimated_cost_cents: Option<u64>,
113    /// Optional tool version id. When set and a [`SchemaDriftGuard`] is
114    /// configured, `tool_input` is validated against the registered input
115    /// schema for this version before the other layers run.
116    pub tool_version_id: Option<ToolVersionId>,
117    /// Optional agent id. When set and a [`BehavioralDriftMonitor`] is
118    /// configured, this call's `estimated_cost_cents` is fed into the
119    /// agent's rolling z-score check.
120    pub agent_id: Option<AgentId>,
121}
122
123/// Result of Airlock inspection
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct AirlockResult {
126    /// Whether the tool call is allowed
127    pub allowed: bool,
128    /// Any detected violation (None if clean)
129    pub violation: Option<AirlockViolation>,
130    /// Whether we're in shadow mode (log-only)
131    pub shadow_mode: bool,
132    /// Summary risk score across all checks
133    pub risk_score: u8,
134    /// Summary risk level
135    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
150/// Main Airlock Inspector
151///
152/// Coordinates all inspection layers and provides a unified interface
153/// for tool call inspection.
154pub struct AirlockInspector {
155    /// Configuration
156    config: AirlockConfig,
157    /// Anti-RCE pattern matcher
158    rce_matcher: RcePatternMatcher,
159    /// Financial circuit breaker
160    velocity_tracker: Arc<VelocityTracker>,
161    /// Data exfiltration shield
162    exfiltration_shield: ExfiltrationShield,
163    /// Schema-drift guard (None == disabled or no schemas registered)
164    schema_drift_guard: Option<Arc<SchemaDriftGuard>>,
165    /// Behavioral-drift monitor (None == disabled or not attached)
166    behavioral_drift_monitor: Option<Arc<BehavioralDriftMonitor>>,
167}
168
169impl AirlockInspector {
170    /// Create a new Airlock inspector from configuration. The schema-drift
171    /// guard starts empty — use [`Self::with_schema_drift_guard`] after
172    /// loading tool versions from the registry.
173    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    /// Builder-style: attach a [`SchemaDriftGuard`] populated from the
198    /// tool registry. Call at gateway boot once tool versions are loaded.
199    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    /// Builder-style: attach a [`BehavioralDriftMonitor`]. Call at gateway
209    /// boot. The monitor is shared (Arc) so other paths — e.g. a future
210    /// worker-side `record_post_call` — can push latency / refusal /
211    /// schema-violation observations using the same per-agent state.
212    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    /// Check if Airlock is in shadow mode (log-only, don't block)
219    pub fn is_shadow_mode(&self) -> bool {
220        matches!(self.config.mode, AirlockMode::Shadow)
221    }
222
223    /// Get reference to current configuration
224    pub fn config(&self) -> &AirlockConfig {
225        &self.config
226    }
227
228    /// Get reference to velocity tracker for recording calls
229    pub fn velocity_tracker(&self) -> Arc<VelocityTracker> {
230        Arc::clone(&self.velocity_tracker)
231    }
232
233    /// Inspect a tool call through all layers
234    ///
235    /// Returns an AirlockResult indicating whether the call should be allowed
236    /// and any detected violations.
237    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        // Layer -1: Behavioral drift — per-agent rolling z-score on
248        // cost_cents. Cheap pre-call signal when agent_id is known.
249        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        // Layer 0: Schema-drift — cheapest signal when we have a tool_version_id
283        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        // Layer 1: Anti-RCE pattern detection
310        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, // Block if enforce mode
324                    violation: Some(violation.clone()),
325                    shadow_mode,
326                    risk_score: violation.risk_score,
327                    risk_level: violation.risk_level,
328                };
329            }
330        }
331
332        // Layer 2: Velocity/circuit breaker
333        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        // Layer 3: Exfiltration shield — domain/IP/credential checks (sync)
355        // plus per-domain data budget (async, mutates state).
356        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            // Per-domain data budget (async, mutates state). Only meaningful
381            // when the destination is an allow-listed domain — the URL/IP
382            // checks above would have already blocked anything outside the
383            // allowlist, so any URL surviving to here is a legitimate egress
384            // we want to count against the budget.
385            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        // All checks passed
423        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    /// Record a completed tool call for velocity tracking
433    ///
434    /// Should be called after a tool call completes successfully.
435    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    /// Clear per-run state for a completed run — velocity tracker and
442    /// exfiltration shield. Should be called when a run completes to free
443    /// memory.
444    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    /// Get current velocity tracker statistics
450    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); // Blocked in enforce mode
519        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); // Allowed in shadow mode
539        assert!(result.shadow_mode);
540        assert!(result.violation.is_some()); // But violation still detected
541    }
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![], // No whitelist
589                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        // Record 3 identical calls (threshold)
641        for _ in 0..3 {
642            inspector.record_call(&ctx).await;
643        }
644
645        // 4th call should trigger loop detection
646        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        // Record some calls
682        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        // Clear the run
689        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}