Skip to main content

fd_policy/airlock/
config.rs

1//! Airlock configuration types
2
3use serde::{Deserialize, Serialize};
4
5/// Airlock operation mode
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
7#[serde(rename_all = "snake_case")]
8pub enum AirlockMode {
9    /// Block violations (production mode)
10    Enforce,
11    /// Log violations but don't block (testing/rollout mode) - default for safety
12    #[default]
13    Shadow,
14}
15
16/// Main Airlock configuration
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct AirlockConfig {
19    /// Operating mode (enforce or shadow)
20    #[serde(default)]
21    pub mode: AirlockMode,
22
23    /// Anti-RCE pattern detection configuration
24    #[serde(default)]
25    pub rce: RceConfig,
26
27    /// Financial circuit breaker configuration
28    #[serde(default)]
29    pub velocity: VelocityConfig,
30
31    /// Data exfiltration shield configuration
32    #[serde(default)]
33    pub exfiltration: ExfiltrationConfig,
34
35    /// Schema-drift validation configuration
36    #[serde(default)]
37    pub schema_drift: SchemaDriftConfig,
38
39    /// Behavioral-drift (per-agent rolling z-score) configuration
40    #[serde(default)]
41    pub behavioral_drift: BehavioralDriftConfig,
42}
43
44impl Default for AirlockConfig {
45    fn default() -> Self {
46        Self {
47            mode: AirlockMode::Shadow,
48            rce: RceConfig::default(),
49            velocity: VelocityConfig::default(),
50            exfiltration: ExfiltrationConfig::default(),
51            schema_drift: SchemaDriftConfig::default(),
52            behavioral_drift: BehavioralDriftConfig::default(),
53        }
54    }
55}
56
57/// Anti-RCE pattern detection configuration
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct RceConfig {
60    /// Enable RCE pattern detection
61    #[serde(default = "default_true")]
62    pub enabled: bool,
63
64    /// Tools to apply RCE detection to
65    #[serde(default = "default_rce_tools")]
66    pub target_tools: Vec<String>,
67
68    /// Custom patterns to add (in addition to built-in)
69    #[serde(default)]
70    pub custom_patterns: Vec<String>,
71}
72
73impl Default for RceConfig {
74    fn default() -> Self {
75        Self {
76            enabled: true,
77            target_tools: default_rce_tools(),
78            custom_patterns: Vec::new(),
79        }
80    }
81}
82
83/// Financial circuit breaker configuration
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct VelocityConfig {
86    /// Enable velocity checking
87    #[serde(default = "default_true")]
88    pub enabled: bool,
89
90    /// Max cost in cents per time window
91    #[serde(default = "default_max_cost_cents")]
92    pub max_cost_cents: u64,
93
94    /// Time window in seconds
95    #[serde(default = "default_window_seconds")]
96    pub window_seconds: u64,
97
98    /// Max identical calls before loop detection triggers
99    #[serde(default = "default_loop_threshold")]
100    pub loop_threshold: u32,
101}
102
103impl Default for VelocityConfig {
104    fn default() -> Self {
105        Self {
106            enabled: true,
107            max_cost_cents: default_max_cost_cents(),
108            window_seconds: default_window_seconds(),
109            loop_threshold: default_loop_threshold(),
110        }
111    }
112}
113
114/// Data exfiltration shield configuration
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct ExfiltrationConfig {
117    /// Enable exfiltration detection
118    #[serde(default = "default_true")]
119    pub enabled: bool,
120
121    /// Network tools to inspect
122    #[serde(default = "default_network_tools")]
123    pub target_tools: Vec<String>,
124
125    /// Allowed domains (whitelist)
126    #[serde(default)]
127    pub allowed_domains: Vec<String>,
128
129    /// Block raw IP addresses (prevents direct C2 connections)
130    #[serde(default = "default_true")]
131    pub block_ip_addresses: bool,
132
133    /// Run the credential-DLP scanner on outbound payloads. Detects cloud
134    /// keys / tokens / Luhn-valid PANs / mod-97-valid IBANs.
135    #[serde(default = "default_true")]
136    pub credential_dlp_enabled: bool,
137
138    /// Maximum outbound bytes to a single domain per run before the shield
139    /// kills further dispatches. `None` disables the budget. The shield
140    /// estimates per-call body size from the serialised tool input.
141    #[serde(default)]
142    pub data_budget_per_domain_bytes: Option<u64>,
143}
144
145impl Default for ExfiltrationConfig {
146    fn default() -> Self {
147        Self {
148            enabled: true,
149            target_tools: default_network_tools(),
150            allowed_domains: Vec::new(),
151            block_ip_addresses: true,
152            credential_dlp_enabled: true,
153            data_budget_per_domain_bytes: None,
154        }
155    }
156}
157
158/// Schema-drift validation configuration
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct SchemaDriftConfig {
161    /// Enable schema-drift checking
162    #[serde(default = "default_true")]
163    pub enabled: bool,
164
165    /// Risk score (0-100) assigned to drift violations. Clamped to 100.
166    #[serde(default = "default_schema_drift_risk_score")]
167    pub risk_score: u8,
168
169    /// What to do when a call carries a `tool_version_id` for which the guard
170    /// holds **no compiled schema** (registered-but-not-yet-populated, or a
171    /// schema that failed to compile).
172    ///
173    /// * `false` (default) — **fail-open**: skip the check and let the call
174    ///   through. This preserves the historical behaviour and never blocks a
175    ///   call merely because its schema is unknown.
176    /// * `true` — **fail-closed**: treat the missing schema as a drift
177    ///   violation and block (enforce) / record (shadow). For a deny-by-default
178    ///   enforcement plane this is the stricter, arguably-correct posture, but
179    ///   it can reject live traffic for any tool version the guard has not seen,
180    ///   so it is opt-in. The gateway exposes it via
181    ///   `FERRUMDECK_SCHEMA_DRIFT_FAIL_CLOSED=true`.
182    #[serde(default = "default_schema_drift_fail_closed")]
183    pub fail_closed_on_unregistered: bool,
184}
185
186impl Default for SchemaDriftConfig {
187    fn default() -> Self {
188        Self {
189            enabled: true,
190            risk_score: default_schema_drift_risk_score(),
191            fail_closed_on_unregistered: default_schema_drift_fail_closed(),
192        }
193    }
194}
195
196fn default_schema_drift_risk_score() -> u8 {
197    70
198}
199
200fn default_schema_drift_fail_closed() -> bool {
201    false
202}
203
204/// Behavioral-drift (per-agent rolling z-score) configuration
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct BehavioralDriftConfig {
207    /// Enable behavioral-drift checking
208    #[serde(default = "default_true")]
209    pub enabled: bool,
210
211    /// Max observations retained per agent per dimension.
212    #[serde(default = "default_behavioral_window")]
213    pub window_size: usize,
214
215    /// Minimum observations required before drift checks fire. z-score on a
216    /// small sample is meaningless — keep this honest.
217    #[serde(default = "default_behavioral_min_observations")]
218    pub min_observations: usize,
219
220    /// Number of standard deviations from the rolling mean that triggers a
221    /// violation. 3.0σ is roughly the 99.7th percentile under a normal
222    /// distribution.
223    #[serde(default = "default_behavioral_z_threshold")]
224    pub z_threshold: f32,
225
226    /// Risk score (0-100) assigned to behavioral-drift violations.
227    #[serde(default = "default_behavioral_risk_score")]
228    pub risk_score: u8,
229}
230
231impl Default for BehavioralDriftConfig {
232    fn default() -> Self {
233        Self {
234            enabled: true,
235            window_size: default_behavioral_window(),
236            min_observations: default_behavioral_min_observations(),
237            z_threshold: default_behavioral_z_threshold(),
238            risk_score: default_behavioral_risk_score(),
239        }
240    }
241}
242
243fn default_behavioral_window() -> usize {
244    100
245}
246
247fn default_behavioral_min_observations() -> usize {
248    20
249}
250
251fn default_behavioral_z_threshold() -> f32 {
252    3.0
253}
254
255fn default_behavioral_risk_score() -> u8 {
256    65
257}
258
259/// Coherence-divergence monitor configuration.
260///
261/// Unlike the per-call layers above, the coherence monitor is *sequential* —
262/// it consumes a run trajectory and flags a stated blocking fact followed by
263/// a contradicting closure action (see [`super::coherence`]). It is driven by
264/// its own [`super::coherence::CoherenceMonitor`] rather than by
265/// [`super::inspector::AirlockInspector::inspect`], so this config is consumed
266/// directly by that monitor and is intentionally not a field of
267/// [`AirlockConfig`].
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct CoherenceConfig {
270    /// Enable coherence-divergence checking.
271    #[serde(default = "default_true")]
272    pub enabled: bool,
273
274    /// Max trajectory events between a stated blocking fact and a
275    /// contradicting closure action for them to be paired. Beyond this the
276    /// fact is treated as stale and dropped.
277    #[serde(default = "default_coherence_lookahead")]
278    pub lookahead: usize,
279
280    /// Risk score (0-100) assigned to coherence-divergence violations.
281    /// Clamped to 100.
282    #[serde(default = "default_coherence_risk_score")]
283    pub risk_score: u8,
284
285    /// Minimum confidence in `[0, 1]` required to surface a divergence —
286    /// guards the false-positive rate.
287    #[serde(default = "default_coherence_min_confidence")]
288    pub min_confidence: f64,
289}
290
291impl Default for CoherenceConfig {
292    fn default() -> Self {
293        Self {
294            enabled: true,
295            lookahead: default_coherence_lookahead(),
296            risk_score: default_coherence_risk_score(),
297            min_confidence: default_coherence_min_confidence(),
298        }
299    }
300}
301
302fn default_coherence_lookahead() -> usize {
303    8
304}
305
306fn default_coherence_risk_score() -> u8 {
307    70
308}
309
310fn default_coherence_min_confidence() -> f64 {
311    0.5
312}
313
314// =============================================================================
315// Default value functions for serde
316// =============================================================================
317
318fn default_true() -> bool {
319    true
320}
321
322fn default_max_cost_cents() -> u64 {
323    100 // $1.00
324}
325
326fn default_window_seconds() -> u64 {
327    10
328}
329
330fn default_loop_threshold() -> u32 {
331    3
332}
333
334fn default_rce_tools() -> Vec<String> {
335    vec![
336        "write_file".to_string(),
337        "create_file".to_string(),
338        "create_or_update_file".to_string(),
339        "python_repl".to_string(),
340        "bash".to_string(),
341        "execute_command".to_string(),
342        "run_script".to_string(),
343        "shell".to_string(),
344    ]
345}
346
347fn default_network_tools() -> Vec<String> {
348    vec![
349        "http_get".to_string(),
350        "http_post".to_string(),
351        "http_request".to_string(),
352        "curl".to_string(),
353        "fetch".to_string(),
354        "requests".to_string(),
355        "webhook".to_string(),
356        "send_email".to_string(),
357    ]
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn test_default_config() {
366        let config = AirlockConfig::default();
367        assert_eq!(config.mode, AirlockMode::Shadow);
368        assert!(config.rce.enabled);
369        assert!(config.velocity.enabled);
370        assert!(config.exfiltration.enabled);
371    }
372
373    #[test]
374    fn test_default_rce_tools() {
375        let tools = default_rce_tools();
376        assert!(tools.contains(&"write_file".to_string()));
377        assert!(tools.contains(&"bash".to_string()));
378    }
379
380    #[test]
381    fn test_velocity_defaults() {
382        let config = VelocityConfig::default();
383        assert_eq!(config.max_cost_cents, 100);
384        assert_eq!(config.window_seconds, 10);
385        assert_eq!(config.loop_threshold, 3);
386    }
387}