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_cache_ttl_secs() -> u64 {
19    300
20}
21
22fn default_confirm_patterns() -> Vec<String> {
23    vec![
24        "rm ".into(),
25        "git push -f".into(),
26        "git push --force".into(),
27        "drop table".into(),
28        "drop database".into(),
29        "truncate ".into(),
30        "$(".into(),
31        "`".into(),
32        "<(".into(),
33        ">(".into(),
34        "<<<".into(),
35        "eval ".into(),
36    ]
37}
38
39fn default_audit_destination() -> String {
40    "stdout".into()
41}
42
43fn default_overflow_threshold() -> usize {
44    50_000
45}
46
47fn default_retention_days() -> u64 {
48    7
49}
50
51fn default_max_overflow_bytes() -> usize {
52    10 * 1024 * 1024 // 10 MiB
53}
54
55/// Configuration for large tool response offload to `SQLite`.
56#[derive(Debug, Clone, Deserialize, Serialize)]
57pub struct OverflowConfig {
58    #[serde(default = "default_overflow_threshold")]
59    pub threshold: usize,
60    #[serde(default = "default_retention_days")]
61    pub retention_days: u64,
62    /// Maximum bytes per overflow entry. `0` means unlimited.
63    #[serde(default = "default_max_overflow_bytes")]
64    pub max_overflow_bytes: usize,
65}
66
67impl Default for OverflowConfig {
68    fn default() -> Self {
69        Self {
70            threshold: default_overflow_threshold(),
71            retention_days: default_retention_days(),
72            max_overflow_bytes: default_max_overflow_bytes(),
73        }
74    }
75}
76
77fn default_anomaly_window() -> usize {
78    10
79}
80
81fn default_anomaly_error_threshold() -> f64 {
82    0.5
83}
84
85fn default_anomaly_critical_threshold() -> f64 {
86    0.8
87}
88
89/// Configuration for the sliding-window anomaly detector.
90#[derive(Debug, Clone, Deserialize, Serialize)]
91pub struct AnomalyConfig {
92    #[serde(default)]
93    pub enabled: bool,
94    #[serde(default = "default_anomaly_window")]
95    pub window_size: usize,
96    #[serde(default = "default_anomaly_error_threshold")]
97    pub error_threshold: f64,
98    #[serde(default = "default_anomaly_critical_threshold")]
99    pub critical_threshold: f64,
100}
101
102impl Default for AnomalyConfig {
103    fn default() -> Self {
104        Self {
105            enabled: false,
106            window_size: default_anomaly_window(),
107            error_threshold: default_anomaly_error_threshold(),
108            critical_threshold: default_anomaly_critical_threshold(),
109        }
110    }
111}
112
113/// Configuration for the tool result cache.
114#[derive(Debug, Clone, Deserialize, Serialize)]
115pub struct ResultCacheConfig {
116    /// Whether caching is enabled. Default: `true`.
117    #[serde(default = "default_true")]
118    pub enabled: bool,
119    /// Time-to-live in seconds. `0` means entries never expire. Default: `300`.
120    #[serde(default = "default_cache_ttl_secs")]
121    pub ttl_secs: u64,
122}
123
124impl Default for ResultCacheConfig {
125    fn default() -> Self {
126        Self {
127            enabled: true,
128            ttl_secs: default_cache_ttl_secs(),
129        }
130    }
131}
132
133fn default_tafc_complexity_threshold() -> f64 {
134    0.6
135}
136
137/// Configuration for Think-Augmented Function Calling (TAFC).
138#[derive(Debug, Clone, Deserialize, Serialize)]
139pub struct TafcConfig {
140    /// Enable TAFC schema augmentation (default: false).
141    #[serde(default)]
142    pub enabled: bool,
143    /// Complexity threshold tau in [0.0, 1.0]; tools with complexity >= tau are augmented.
144    /// Default: 0.6
145    #[serde(default = "default_tafc_complexity_threshold")]
146    pub complexity_threshold: f64,
147}
148
149impl Default for TafcConfig {
150    fn default() -> Self {
151        Self {
152            enabled: false,
153            complexity_threshold: default_tafc_complexity_threshold(),
154        }
155    }
156}
157
158impl TafcConfig {
159    /// Validate and clamp `complexity_threshold` to \[0.0, 1.0\]. Reset NaN/Infinity to 0.6.
160    #[must_use]
161    pub fn validated(mut self) -> Self {
162        if self.complexity_threshold.is_finite() {
163            self.complexity_threshold = self.complexity_threshold.clamp(0.0, 1.0);
164        } else {
165            self.complexity_threshold = 0.6;
166        }
167        self
168    }
169}
170
171fn default_boost_per_dep() -> f32 {
172    0.15
173}
174
175fn default_max_total_boost() -> f32 {
176    0.2
177}
178
179/// Dependency specification for a single tool.
180#[derive(Debug, Clone, Default, Deserialize, Serialize)]
181pub struct ToolDependency {
182    /// Hard prerequisites: tool is hidden until ALL of these have completed successfully.
183    #[serde(default, skip_serializing_if = "Vec::is_empty")]
184    pub requires: Vec<String>,
185    /// Soft prerequisites: tool gets a similarity boost when these have completed.
186    #[serde(default, skip_serializing_if = "Vec::is_empty")]
187    pub prefers: Vec<String>,
188}
189
190/// Configuration for the tool dependency graph feature.
191#[derive(Debug, Clone, Deserialize, Serialize)]
192pub struct DependencyConfig {
193    /// Whether dependency gating is enabled. Default: false.
194    #[serde(default)]
195    pub enabled: bool,
196    /// Similarity boost added per satisfied `prefers` dependency. Default: 0.15.
197    #[serde(default = "default_boost_per_dep")]
198    pub boost_per_dep: f32,
199    /// Maximum total boost applied regardless of how many `prefers` deps are met. Default: 0.2.
200    #[serde(default = "default_max_total_boost")]
201    pub max_total_boost: f32,
202    /// Per-tool dependency rules. Key is `tool_id`.
203    #[serde(default)]
204    pub rules: std::collections::HashMap<String, ToolDependency>,
205}
206
207impl Default for DependencyConfig {
208    fn default() -> Self {
209        Self {
210            enabled: false,
211            boost_per_dep: default_boost_per_dep(),
212            max_total_boost: default_max_total_boost(),
213            rules: std::collections::HashMap::new(),
214        }
215    }
216}
217
218/// Top-level configuration for tool execution.
219#[derive(Debug, Deserialize, Serialize)]
220pub struct ToolsConfig {
221    #[serde(default = "default_true")]
222    pub enabled: bool,
223    #[serde(default = "default_true")]
224    pub summarize_output: bool,
225    #[serde(default)]
226    pub shell: ShellConfig,
227    #[serde(default)]
228    pub scrape: ScrapeConfig,
229    #[serde(default)]
230    pub audit: AuditConfig,
231    #[serde(default)]
232    pub permissions: Option<PermissionsConfig>,
233    #[serde(default)]
234    pub filters: crate::filter::FilterConfig,
235    #[serde(default)]
236    pub overflow: OverflowConfig,
237    #[serde(default)]
238    pub anomaly: AnomalyConfig,
239    #[serde(default)]
240    pub result_cache: ResultCacheConfig,
241    #[serde(default)]
242    pub tafc: TafcConfig,
243    #[serde(default)]
244    pub dependencies: DependencyConfig,
245    /// Declarative policy compiler for tool call authorization.
246    #[cfg(feature = "policy-enforcer")]
247    #[serde(default)]
248    pub policy: PolicyConfig,
249}
250
251impl ToolsConfig {
252    /// Build a `PermissionPolicy` from explicit config or legacy shell fields.
253    #[must_use]
254    pub fn permission_policy(&self, autonomy_level: AutonomyLevel) -> PermissionPolicy {
255        let policy = if let Some(ref perms) = self.permissions {
256            PermissionPolicy::from(perms.clone())
257        } else {
258            PermissionPolicy::from_legacy(
259                &self.shell.blocked_commands,
260                &self.shell.confirm_patterns,
261            )
262        };
263        policy.with_autonomy(autonomy_level)
264    }
265}
266
267/// Shell-specific configuration: timeout, command blocklist, and allowlist overrides.
268#[derive(Debug, Deserialize, Serialize)]
269pub struct ShellConfig {
270    #[serde(default = "default_timeout")]
271    pub timeout: u64,
272    #[serde(default)]
273    pub blocked_commands: Vec<String>,
274    #[serde(default)]
275    pub allowed_commands: Vec<String>,
276    #[serde(default)]
277    pub allowed_paths: Vec<String>,
278    #[serde(default = "default_true")]
279    pub allow_network: bool,
280    #[serde(default = "default_confirm_patterns")]
281    pub confirm_patterns: Vec<String>,
282}
283
284/// Configuration for audit logging of tool executions.
285#[derive(Debug, Deserialize, Serialize)]
286pub struct AuditConfig {
287    #[serde(default)]
288    pub enabled: bool,
289    #[serde(default = "default_audit_destination")]
290    pub destination: String,
291}
292
293impl Default for ToolsConfig {
294    fn default() -> Self {
295        Self {
296            enabled: true,
297            summarize_output: true,
298            shell: ShellConfig::default(),
299            scrape: ScrapeConfig::default(),
300            audit: AuditConfig::default(),
301            permissions: None,
302            filters: crate::filter::FilterConfig::default(),
303            overflow: OverflowConfig::default(),
304            anomaly: AnomalyConfig::default(),
305            result_cache: ResultCacheConfig::default(),
306            tafc: TafcConfig::default(),
307            dependencies: DependencyConfig::default(),
308            #[cfg(feature = "policy-enforcer")]
309            policy: PolicyConfig::default(),
310        }
311    }
312}
313
314impl Default for ShellConfig {
315    fn default() -> Self {
316        Self {
317            timeout: default_timeout(),
318            blocked_commands: Vec::new(),
319            allowed_commands: Vec::new(),
320            allowed_paths: Vec::new(),
321            allow_network: true,
322            confirm_patterns: default_confirm_patterns(),
323        }
324    }
325}
326
327impl Default for AuditConfig {
328    fn default() -> Self {
329        Self {
330            enabled: false,
331            destination: default_audit_destination(),
332        }
333    }
334}
335
336fn default_scrape_timeout() -> u64 {
337    15
338}
339
340fn default_max_body_bytes() -> usize {
341    4_194_304
342}
343
344/// Configuration for the web scrape tool.
345#[derive(Debug, Deserialize, Serialize)]
346pub struct ScrapeConfig {
347    #[serde(default = "default_scrape_timeout")]
348    pub timeout: u64,
349    #[serde(default = "default_max_body_bytes")]
350    pub max_body_bytes: usize,
351}
352
353impl Default for ScrapeConfig {
354    fn default() -> Self {
355        Self {
356            timeout: default_scrape_timeout(),
357            max_body_bytes: default_max_body_bytes(),
358        }
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn deserialize_default_config() {
368        let toml_str = r#"
369            enabled = true
370
371            [shell]
372            timeout = 60
373            blocked_commands = ["rm -rf /", "sudo"]
374        "#;
375
376        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
377        assert!(config.enabled);
378        assert_eq!(config.shell.timeout, 60);
379        assert_eq!(config.shell.blocked_commands.len(), 2);
380        assert_eq!(config.shell.blocked_commands[0], "rm -rf /");
381        assert_eq!(config.shell.blocked_commands[1], "sudo");
382    }
383
384    #[test]
385    fn empty_blocked_commands() {
386        let toml_str = r"
387            [shell]
388            timeout = 30
389        ";
390
391        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
392        assert!(config.enabled);
393        assert_eq!(config.shell.timeout, 30);
394        assert!(config.shell.blocked_commands.is_empty());
395    }
396
397    #[test]
398    fn default_tools_config() {
399        let config = ToolsConfig::default();
400        assert!(config.enabled);
401        assert!(config.summarize_output);
402        assert_eq!(config.shell.timeout, 30);
403        assert!(config.shell.blocked_commands.is_empty());
404        assert!(!config.audit.enabled);
405    }
406
407    #[test]
408    fn tools_summarize_output_default_true() {
409        let config = ToolsConfig::default();
410        assert!(config.summarize_output);
411    }
412
413    #[test]
414    fn tools_summarize_output_parsing() {
415        let toml_str = r"
416            summarize_output = true
417        ";
418        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
419        assert!(config.summarize_output);
420    }
421
422    #[test]
423    fn default_shell_config() {
424        let config = ShellConfig::default();
425        assert_eq!(config.timeout, 30);
426        assert!(config.blocked_commands.is_empty());
427        assert!(config.allowed_paths.is_empty());
428        assert!(config.allow_network);
429        assert!(!config.confirm_patterns.is_empty());
430    }
431
432    #[test]
433    fn deserialize_omitted_fields_use_defaults() {
434        let toml_str = "";
435        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
436        assert!(config.enabled);
437        assert_eq!(config.shell.timeout, 30);
438        assert!(config.shell.blocked_commands.is_empty());
439        assert!(config.shell.allow_network);
440        assert!(!config.shell.confirm_patterns.is_empty());
441        assert_eq!(config.scrape.timeout, 15);
442        assert_eq!(config.scrape.max_body_bytes, 4_194_304);
443        assert!(!config.audit.enabled);
444        assert_eq!(config.audit.destination, "stdout");
445        assert!(config.summarize_output);
446    }
447
448    #[test]
449    fn default_scrape_config() {
450        let config = ScrapeConfig::default();
451        assert_eq!(config.timeout, 15);
452        assert_eq!(config.max_body_bytes, 4_194_304);
453    }
454
455    #[test]
456    fn deserialize_scrape_config() {
457        let toml_str = r"
458            [scrape]
459            timeout = 30
460            max_body_bytes = 2097152
461        ";
462
463        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
464        assert_eq!(config.scrape.timeout, 30);
465        assert_eq!(config.scrape.max_body_bytes, 2_097_152);
466    }
467
468    #[test]
469    fn tools_config_default_includes_scrape() {
470        let config = ToolsConfig::default();
471        assert_eq!(config.scrape.timeout, 15);
472        assert_eq!(config.scrape.max_body_bytes, 4_194_304);
473    }
474
475    #[test]
476    fn deserialize_allowed_commands() {
477        let toml_str = r#"
478            [shell]
479            timeout = 30
480            allowed_commands = ["curl", "wget"]
481        "#;
482
483        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
484        assert_eq!(config.shell.allowed_commands, vec!["curl", "wget"]);
485    }
486
487    #[test]
488    fn default_allowed_commands_empty() {
489        let config = ShellConfig::default();
490        assert!(config.allowed_commands.is_empty());
491    }
492
493    #[test]
494    fn deserialize_shell_security_fields() {
495        let toml_str = r#"
496            [shell]
497            allowed_paths = ["/tmp", "/home/user"]
498            allow_network = false
499            confirm_patterns = ["rm ", "drop table"]
500        "#;
501
502        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
503        assert_eq!(config.shell.allowed_paths, vec!["/tmp", "/home/user"]);
504        assert!(!config.shell.allow_network);
505        assert_eq!(config.shell.confirm_patterns, vec!["rm ", "drop table"]);
506    }
507
508    #[test]
509    fn deserialize_audit_config() {
510        let toml_str = r#"
511            [audit]
512            enabled = true
513            destination = "/var/log/zeph-audit.log"
514        "#;
515
516        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
517        assert!(config.audit.enabled);
518        assert_eq!(config.audit.destination, "/var/log/zeph-audit.log");
519    }
520
521    #[test]
522    fn default_audit_config() {
523        let config = AuditConfig::default();
524        assert!(!config.enabled);
525        assert_eq!(config.destination, "stdout");
526    }
527
528    #[test]
529    fn permission_policy_from_legacy_fields() {
530        let config = ToolsConfig {
531            shell: ShellConfig {
532                blocked_commands: vec!["sudo".to_owned()],
533                confirm_patterns: vec!["rm ".to_owned()],
534                ..ShellConfig::default()
535            },
536            ..ToolsConfig::default()
537        };
538        let policy = config.permission_policy(AutonomyLevel::Supervised);
539        assert_eq!(
540            policy.check("bash", "sudo apt"),
541            crate::permissions::PermissionAction::Deny
542        );
543        assert_eq!(
544            policy.check("bash", "rm file"),
545            crate::permissions::PermissionAction::Ask
546        );
547    }
548
549    #[test]
550    fn permission_policy_from_explicit_config() {
551        let toml_str = r#"
552            [permissions]
553            [[permissions.bash]]
554            pattern = "*sudo*"
555            action = "deny"
556        "#;
557        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
558        let policy = config.permission_policy(AutonomyLevel::Supervised);
559        assert_eq!(
560            policy.check("bash", "sudo rm"),
561            crate::permissions::PermissionAction::Deny
562        );
563    }
564
565    #[test]
566    fn permission_policy_default_uses_legacy() {
567        let config = ToolsConfig::default();
568        assert!(config.permissions.is_none());
569        let policy = config.permission_policy(AutonomyLevel::Supervised);
570        // Default ShellConfig has confirm_patterns, so legacy rules are generated
571        assert!(!config.shell.confirm_patterns.is_empty());
572        assert!(policy.rules().contains_key("bash"));
573    }
574
575    #[test]
576    fn deserialize_overflow_config_full() {
577        let toml_str = r"
578            [overflow]
579            threshold = 100000
580            retention_days = 14
581            max_overflow_bytes = 5242880
582        ";
583        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
584        assert_eq!(config.overflow.threshold, 100_000);
585        assert_eq!(config.overflow.retention_days, 14);
586        assert_eq!(config.overflow.max_overflow_bytes, 5_242_880);
587    }
588
589    #[test]
590    fn deserialize_overflow_config_unknown_dir_field_is_ignored() {
591        // Old configs with `dir = "..."` must not fail deserialization.
592        let toml_str = r#"
593            [overflow]
594            threshold = 75000
595            dir = "/tmp/overflow"
596        "#;
597        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
598        assert_eq!(config.overflow.threshold, 75_000);
599    }
600
601    #[test]
602    fn deserialize_overflow_config_partial_uses_defaults() {
603        let toml_str = r"
604            [overflow]
605            threshold = 75000
606        ";
607        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
608        assert_eq!(config.overflow.threshold, 75_000);
609        assert_eq!(config.overflow.retention_days, 7);
610    }
611
612    #[test]
613    fn deserialize_overflow_config_omitted_uses_defaults() {
614        let config: ToolsConfig = toml::from_str("").unwrap();
615        assert_eq!(config.overflow.threshold, 50_000);
616        assert_eq!(config.overflow.retention_days, 7);
617        assert_eq!(config.overflow.max_overflow_bytes, 10 * 1024 * 1024);
618    }
619
620    #[test]
621    fn result_cache_config_defaults() {
622        let config = ResultCacheConfig::default();
623        assert!(config.enabled);
624        assert_eq!(config.ttl_secs, 300);
625    }
626
627    #[test]
628    fn deserialize_result_cache_config() {
629        let toml_str = r"
630            [result_cache]
631            enabled = false
632            ttl_secs = 60
633        ";
634        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
635        assert!(!config.result_cache.enabled);
636        assert_eq!(config.result_cache.ttl_secs, 60);
637    }
638
639    #[test]
640    fn result_cache_omitted_uses_defaults() {
641        let config: ToolsConfig = toml::from_str("").unwrap();
642        assert!(config.result_cache.enabled);
643        assert_eq!(config.result_cache.ttl_secs, 300);
644    }
645
646    #[test]
647    fn result_cache_ttl_zero_is_valid() {
648        let toml_str = r"
649            [result_cache]
650            ttl_secs = 0
651        ";
652        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
653        assert_eq!(config.result_cache.ttl_secs, 0);
654    }
655}