Skip to main content

zeph_tools/
config.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use serde::{Deserialize, Serialize};
5
6use crate::permissions::{AutonomyLevel, PermissionPolicy, PermissionsConfig};
7#[cfg(feature = "policy-enforcer")]
8use crate::policy::PolicyConfig;
9
10fn default_true() -> bool {
11    true
12}
13
14fn default_timeout() -> u64 {
15    30
16}
17
18fn default_confirm_patterns() -> Vec<String> {
19    vec![
20        "rm ".into(),
21        "git push -f".into(),
22        "git push --force".into(),
23        "drop table".into(),
24        "drop database".into(),
25        "truncate ".into(),
26        "$(".into(),
27        "`".into(),
28        "<(".into(),
29        ">(".into(),
30        "<<<".into(),
31        "eval ".into(),
32    ]
33}
34
35fn default_audit_destination() -> String {
36    "stdout".into()
37}
38
39fn default_overflow_threshold() -> usize {
40    50_000
41}
42
43fn default_retention_days() -> u64 {
44    7
45}
46
47fn default_max_overflow_bytes() -> usize {
48    10 * 1024 * 1024 // 10 MiB
49}
50
51/// Configuration for large tool response offload to `SQLite`.
52#[derive(Debug, Clone, Deserialize, Serialize)]
53pub struct OverflowConfig {
54    #[serde(default = "default_overflow_threshold")]
55    pub threshold: usize,
56    #[serde(default = "default_retention_days")]
57    pub retention_days: u64,
58    /// Maximum bytes per overflow entry. `0` means unlimited.
59    #[serde(default = "default_max_overflow_bytes")]
60    pub max_overflow_bytes: usize,
61}
62
63impl Default for OverflowConfig {
64    fn default() -> Self {
65        Self {
66            threshold: default_overflow_threshold(),
67            retention_days: default_retention_days(),
68            max_overflow_bytes: default_max_overflow_bytes(),
69        }
70    }
71}
72
73fn default_anomaly_window() -> usize {
74    10
75}
76
77fn default_anomaly_error_threshold() -> f64 {
78    0.5
79}
80
81fn default_anomaly_critical_threshold() -> f64 {
82    0.8
83}
84
85/// Configuration for the sliding-window anomaly detector.
86#[derive(Debug, Clone, Deserialize, Serialize)]
87pub struct AnomalyConfig {
88    #[serde(default)]
89    pub enabled: bool,
90    #[serde(default = "default_anomaly_window")]
91    pub window_size: usize,
92    #[serde(default = "default_anomaly_error_threshold")]
93    pub error_threshold: f64,
94    #[serde(default = "default_anomaly_critical_threshold")]
95    pub critical_threshold: f64,
96}
97
98impl Default for AnomalyConfig {
99    fn default() -> Self {
100        Self {
101            enabled: false,
102            window_size: default_anomaly_window(),
103            error_threshold: default_anomaly_error_threshold(),
104            critical_threshold: default_anomaly_critical_threshold(),
105        }
106    }
107}
108
109/// Top-level configuration for tool execution.
110#[derive(Debug, Deserialize, Serialize)]
111pub struct ToolsConfig {
112    #[serde(default = "default_true")]
113    pub enabled: bool,
114    #[serde(default = "default_true")]
115    pub summarize_output: bool,
116    #[serde(default)]
117    pub shell: ShellConfig,
118    #[serde(default)]
119    pub scrape: ScrapeConfig,
120    #[serde(default)]
121    pub audit: AuditConfig,
122    #[serde(default)]
123    pub permissions: Option<PermissionsConfig>,
124    #[serde(default)]
125    pub filters: crate::filter::FilterConfig,
126    #[serde(default)]
127    pub overflow: OverflowConfig,
128    #[serde(default)]
129    pub anomaly: AnomalyConfig,
130    /// Declarative policy compiler for tool call authorization.
131    #[cfg(feature = "policy-enforcer")]
132    #[serde(default)]
133    pub policy: PolicyConfig,
134}
135
136impl ToolsConfig {
137    /// Build a `PermissionPolicy` from explicit config or legacy shell fields.
138    #[must_use]
139    pub fn permission_policy(&self, autonomy_level: AutonomyLevel) -> PermissionPolicy {
140        let policy = if let Some(ref perms) = self.permissions {
141            PermissionPolicy::from(perms.clone())
142        } else {
143            PermissionPolicy::from_legacy(
144                &self.shell.blocked_commands,
145                &self.shell.confirm_patterns,
146            )
147        };
148        policy.with_autonomy(autonomy_level)
149    }
150}
151
152/// Shell-specific configuration: timeout, command blocklist, and allowlist overrides.
153#[derive(Debug, Deserialize, Serialize)]
154pub struct ShellConfig {
155    #[serde(default = "default_timeout")]
156    pub timeout: u64,
157    #[serde(default)]
158    pub blocked_commands: Vec<String>,
159    #[serde(default)]
160    pub allowed_commands: Vec<String>,
161    #[serde(default)]
162    pub allowed_paths: Vec<String>,
163    #[serde(default = "default_true")]
164    pub allow_network: bool,
165    #[serde(default = "default_confirm_patterns")]
166    pub confirm_patterns: Vec<String>,
167}
168
169/// Configuration for audit logging of tool executions.
170#[derive(Debug, Deserialize, Serialize)]
171pub struct AuditConfig {
172    #[serde(default)]
173    pub enabled: bool,
174    #[serde(default = "default_audit_destination")]
175    pub destination: String,
176}
177
178impl Default for ToolsConfig {
179    fn default() -> Self {
180        Self {
181            enabled: true,
182            summarize_output: true,
183            shell: ShellConfig::default(),
184            scrape: ScrapeConfig::default(),
185            audit: AuditConfig::default(),
186            permissions: None,
187            filters: crate::filter::FilterConfig::default(),
188            overflow: OverflowConfig::default(),
189            anomaly: AnomalyConfig::default(),
190            #[cfg(feature = "policy-enforcer")]
191            policy: PolicyConfig::default(),
192        }
193    }
194}
195
196impl Default for ShellConfig {
197    fn default() -> Self {
198        Self {
199            timeout: default_timeout(),
200            blocked_commands: Vec::new(),
201            allowed_commands: Vec::new(),
202            allowed_paths: Vec::new(),
203            allow_network: true,
204            confirm_patterns: default_confirm_patterns(),
205        }
206    }
207}
208
209impl Default for AuditConfig {
210    fn default() -> Self {
211        Self {
212            enabled: false,
213            destination: default_audit_destination(),
214        }
215    }
216}
217
218fn default_scrape_timeout() -> u64 {
219    15
220}
221
222fn default_max_body_bytes() -> usize {
223    4_194_304
224}
225
226/// Configuration for the web scrape tool.
227#[derive(Debug, Deserialize, Serialize)]
228pub struct ScrapeConfig {
229    #[serde(default = "default_scrape_timeout")]
230    pub timeout: u64,
231    #[serde(default = "default_max_body_bytes")]
232    pub max_body_bytes: usize,
233}
234
235impl Default for ScrapeConfig {
236    fn default() -> Self {
237        Self {
238            timeout: default_scrape_timeout(),
239            max_body_bytes: default_max_body_bytes(),
240        }
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn deserialize_default_config() {
250        let toml_str = r#"
251            enabled = true
252
253            [shell]
254            timeout = 60
255            blocked_commands = ["rm -rf /", "sudo"]
256        "#;
257
258        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
259        assert!(config.enabled);
260        assert_eq!(config.shell.timeout, 60);
261        assert_eq!(config.shell.blocked_commands.len(), 2);
262        assert_eq!(config.shell.blocked_commands[0], "rm -rf /");
263        assert_eq!(config.shell.blocked_commands[1], "sudo");
264    }
265
266    #[test]
267    fn empty_blocked_commands() {
268        let toml_str = r"
269            [shell]
270            timeout = 30
271        ";
272
273        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
274        assert!(config.enabled);
275        assert_eq!(config.shell.timeout, 30);
276        assert!(config.shell.blocked_commands.is_empty());
277    }
278
279    #[test]
280    fn default_tools_config() {
281        let config = ToolsConfig::default();
282        assert!(config.enabled);
283        assert!(config.summarize_output);
284        assert_eq!(config.shell.timeout, 30);
285        assert!(config.shell.blocked_commands.is_empty());
286        assert!(!config.audit.enabled);
287    }
288
289    #[test]
290    fn tools_summarize_output_default_true() {
291        let config = ToolsConfig::default();
292        assert!(config.summarize_output);
293    }
294
295    #[test]
296    fn tools_summarize_output_parsing() {
297        let toml_str = r"
298            summarize_output = true
299        ";
300        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
301        assert!(config.summarize_output);
302    }
303
304    #[test]
305    fn default_shell_config() {
306        let config = ShellConfig::default();
307        assert_eq!(config.timeout, 30);
308        assert!(config.blocked_commands.is_empty());
309        assert!(config.allowed_paths.is_empty());
310        assert!(config.allow_network);
311        assert!(!config.confirm_patterns.is_empty());
312    }
313
314    #[test]
315    fn deserialize_omitted_fields_use_defaults() {
316        let toml_str = "";
317        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
318        assert!(config.enabled);
319        assert_eq!(config.shell.timeout, 30);
320        assert!(config.shell.blocked_commands.is_empty());
321        assert!(config.shell.allow_network);
322        assert!(!config.shell.confirm_patterns.is_empty());
323        assert_eq!(config.scrape.timeout, 15);
324        assert_eq!(config.scrape.max_body_bytes, 4_194_304);
325        assert!(!config.audit.enabled);
326        assert_eq!(config.audit.destination, "stdout");
327        assert!(config.summarize_output);
328    }
329
330    #[test]
331    fn default_scrape_config() {
332        let config = ScrapeConfig::default();
333        assert_eq!(config.timeout, 15);
334        assert_eq!(config.max_body_bytes, 4_194_304);
335    }
336
337    #[test]
338    fn deserialize_scrape_config() {
339        let toml_str = r"
340            [scrape]
341            timeout = 30
342            max_body_bytes = 2097152
343        ";
344
345        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
346        assert_eq!(config.scrape.timeout, 30);
347        assert_eq!(config.scrape.max_body_bytes, 2_097_152);
348    }
349
350    #[test]
351    fn tools_config_default_includes_scrape() {
352        let config = ToolsConfig::default();
353        assert_eq!(config.scrape.timeout, 15);
354        assert_eq!(config.scrape.max_body_bytes, 4_194_304);
355    }
356
357    #[test]
358    fn deserialize_allowed_commands() {
359        let toml_str = r#"
360            [shell]
361            timeout = 30
362            allowed_commands = ["curl", "wget"]
363        "#;
364
365        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
366        assert_eq!(config.shell.allowed_commands, vec!["curl", "wget"]);
367    }
368
369    #[test]
370    fn default_allowed_commands_empty() {
371        let config = ShellConfig::default();
372        assert!(config.allowed_commands.is_empty());
373    }
374
375    #[test]
376    fn deserialize_shell_security_fields() {
377        let toml_str = r#"
378            [shell]
379            allowed_paths = ["/tmp", "/home/user"]
380            allow_network = false
381            confirm_patterns = ["rm ", "drop table"]
382        "#;
383
384        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
385        assert_eq!(config.shell.allowed_paths, vec!["/tmp", "/home/user"]);
386        assert!(!config.shell.allow_network);
387        assert_eq!(config.shell.confirm_patterns, vec!["rm ", "drop table"]);
388    }
389
390    #[test]
391    fn deserialize_audit_config() {
392        let toml_str = r#"
393            [audit]
394            enabled = true
395            destination = "/var/log/zeph-audit.log"
396        "#;
397
398        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
399        assert!(config.audit.enabled);
400        assert_eq!(config.audit.destination, "/var/log/zeph-audit.log");
401    }
402
403    #[test]
404    fn default_audit_config() {
405        let config = AuditConfig::default();
406        assert!(!config.enabled);
407        assert_eq!(config.destination, "stdout");
408    }
409
410    #[test]
411    fn permission_policy_from_legacy_fields() {
412        let config = ToolsConfig {
413            shell: ShellConfig {
414                blocked_commands: vec!["sudo".to_owned()],
415                confirm_patterns: vec!["rm ".to_owned()],
416                ..ShellConfig::default()
417            },
418            ..ToolsConfig::default()
419        };
420        let policy = config.permission_policy(AutonomyLevel::Supervised);
421        assert_eq!(
422            policy.check("bash", "sudo apt"),
423            crate::permissions::PermissionAction::Deny
424        );
425        assert_eq!(
426            policy.check("bash", "rm file"),
427            crate::permissions::PermissionAction::Ask
428        );
429    }
430
431    #[test]
432    fn permission_policy_from_explicit_config() {
433        let toml_str = r#"
434            [permissions]
435            [[permissions.bash]]
436            pattern = "*sudo*"
437            action = "deny"
438        "#;
439        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
440        let policy = config.permission_policy(AutonomyLevel::Supervised);
441        assert_eq!(
442            policy.check("bash", "sudo rm"),
443            crate::permissions::PermissionAction::Deny
444        );
445    }
446
447    #[test]
448    fn permission_policy_default_uses_legacy() {
449        let config = ToolsConfig::default();
450        assert!(config.permissions.is_none());
451        let policy = config.permission_policy(AutonomyLevel::Supervised);
452        // Default ShellConfig has confirm_patterns, so legacy rules are generated
453        assert!(!config.shell.confirm_patterns.is_empty());
454        assert!(policy.rules().contains_key("bash"));
455    }
456
457    #[test]
458    fn deserialize_overflow_config_full() {
459        let toml_str = r"
460            [overflow]
461            threshold = 100000
462            retention_days = 14
463            max_overflow_bytes = 5242880
464        ";
465        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
466        assert_eq!(config.overflow.threshold, 100_000);
467        assert_eq!(config.overflow.retention_days, 14);
468        assert_eq!(config.overflow.max_overflow_bytes, 5_242_880);
469    }
470
471    #[test]
472    fn deserialize_overflow_config_unknown_dir_field_is_ignored() {
473        // Old configs with `dir = "..."` must not fail deserialization.
474        let toml_str = r#"
475            [overflow]
476            threshold = 75000
477            dir = "/tmp/overflow"
478        "#;
479        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
480        assert_eq!(config.overflow.threshold, 75_000);
481    }
482
483    #[test]
484    fn deserialize_overflow_config_partial_uses_defaults() {
485        let toml_str = r"
486            [overflow]
487            threshold = 75000
488        ";
489        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
490        assert_eq!(config.overflow.threshold, 75_000);
491        assert_eq!(config.overflow.retention_days, 7);
492    }
493
494    #[test]
495    fn deserialize_overflow_config_omitted_uses_defaults() {
496        let config: ToolsConfig = toml::from_str("").unwrap();
497        assert_eq!(config.overflow.threshold, 50_000);
498        assert_eq!(config.overflow.retention_days, 7);
499        assert_eq!(config.overflow.max_overflow_bytes, 10 * 1024 * 1024);
500    }
501}