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 = "default_true")]
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: true,
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
218fn default_retry_max_attempts() -> usize {
219    2
220}
221
222fn default_retry_base_ms() -> u64 {
223    500
224}
225
226fn default_retry_max_ms() -> u64 {
227    5_000
228}
229
230fn default_retry_budget_secs() -> u64 {
231    30
232}
233
234/// Configuration for tool error retry behavior.
235#[derive(Debug, Clone, Deserialize, Serialize)]
236pub struct RetryConfig {
237    /// Maximum retry attempts for transient errors per tool call. 0 = disabled.
238    #[serde(default = "default_retry_max_attempts")]
239    pub max_attempts: usize,
240    /// Base delay (ms) for exponential backoff.
241    #[serde(default = "default_retry_base_ms")]
242    pub base_ms: u64,
243    /// Maximum delay cap (ms) for exponential backoff.
244    #[serde(default = "default_retry_max_ms")]
245    pub max_ms: u64,
246    /// Maximum wall-clock time (seconds) for all retries of a single tool call. 0 = unlimited.
247    #[serde(default = "default_retry_budget_secs")]
248    pub budget_secs: u64,
249    /// Provider name from `[[llm.providers]]` for LLM-based parameter reformatting on
250    /// `InvalidParameters`/`TypeMismatch` errors. Empty string = disabled.
251    #[serde(default)]
252    pub parameter_reformat_provider: String,
253}
254
255impl Default for RetryConfig {
256    fn default() -> Self {
257        Self {
258            max_attempts: default_retry_max_attempts(),
259            base_ms: default_retry_base_ms(),
260            max_ms: default_retry_max_ms(),
261            budget_secs: default_retry_budget_secs(),
262            parameter_reformat_provider: String::new(),
263        }
264    }
265}
266
267/// Top-level configuration for tool execution.
268#[derive(Debug, Deserialize, Serialize)]
269pub struct ToolsConfig {
270    #[serde(default = "default_true")]
271    pub enabled: bool,
272    #[serde(default = "default_true")]
273    pub summarize_output: bool,
274    #[serde(default)]
275    pub shell: ShellConfig,
276    #[serde(default)]
277    pub scrape: ScrapeConfig,
278    #[serde(default)]
279    pub audit: AuditConfig,
280    #[serde(default)]
281    pub permissions: Option<PermissionsConfig>,
282    #[serde(default)]
283    pub filters: crate::filter::FilterConfig,
284    #[serde(default)]
285    pub overflow: OverflowConfig,
286    #[serde(default)]
287    pub anomaly: AnomalyConfig,
288    #[serde(default)]
289    pub result_cache: ResultCacheConfig,
290    #[serde(default)]
291    pub tafc: TafcConfig,
292    #[serde(default)]
293    pub dependencies: DependencyConfig,
294    #[serde(default)]
295    pub retry: RetryConfig,
296    /// Declarative policy compiler for tool call authorization.
297    #[cfg(feature = "policy-enforcer")]
298    #[serde(default)]
299    pub policy: PolicyConfig,
300}
301
302impl ToolsConfig {
303    /// Build a `PermissionPolicy` from explicit config or legacy shell fields.
304    #[must_use]
305    pub fn permission_policy(&self, autonomy_level: AutonomyLevel) -> PermissionPolicy {
306        let policy = if let Some(ref perms) = self.permissions {
307            PermissionPolicy::from(perms.clone())
308        } else {
309            PermissionPolicy::from_legacy(
310                &self.shell.blocked_commands,
311                &self.shell.confirm_patterns,
312            )
313        };
314        policy.with_autonomy(autonomy_level)
315    }
316}
317
318/// Shell-specific configuration: timeout, command blocklist, and allowlist overrides.
319#[derive(Debug, Deserialize, Serialize)]
320pub struct ShellConfig {
321    #[serde(default = "default_timeout")]
322    pub timeout: u64,
323    #[serde(default)]
324    pub blocked_commands: Vec<String>,
325    #[serde(default)]
326    pub allowed_commands: Vec<String>,
327    #[serde(default)]
328    pub allowed_paths: Vec<String>,
329    #[serde(default = "default_true")]
330    pub allow_network: bool,
331    #[serde(default = "default_confirm_patterns")]
332    pub confirm_patterns: Vec<String>,
333}
334
335/// Configuration for audit logging of tool executions.
336#[derive(Debug, Deserialize, Serialize)]
337pub struct AuditConfig {
338    #[serde(default = "default_true")]
339    pub enabled: bool,
340    #[serde(default = "default_audit_destination")]
341    pub destination: String,
342}
343
344impl Default for ToolsConfig {
345    fn default() -> Self {
346        Self {
347            enabled: true,
348            summarize_output: true,
349            shell: ShellConfig::default(),
350            scrape: ScrapeConfig::default(),
351            audit: AuditConfig::default(),
352            permissions: None,
353            filters: crate::filter::FilterConfig::default(),
354            overflow: OverflowConfig::default(),
355            anomaly: AnomalyConfig::default(),
356            result_cache: ResultCacheConfig::default(),
357            tafc: TafcConfig::default(),
358            dependencies: DependencyConfig::default(),
359            retry: RetryConfig::default(),
360            #[cfg(feature = "policy-enforcer")]
361            policy: PolicyConfig::default(),
362        }
363    }
364}
365
366impl Default for ShellConfig {
367    fn default() -> Self {
368        Self {
369            timeout: default_timeout(),
370            blocked_commands: Vec::new(),
371            allowed_commands: Vec::new(),
372            allowed_paths: Vec::new(),
373            allow_network: true,
374            confirm_patterns: default_confirm_patterns(),
375        }
376    }
377}
378
379impl Default for AuditConfig {
380    fn default() -> Self {
381        Self {
382            enabled: true,
383            destination: default_audit_destination(),
384        }
385    }
386}
387
388fn default_scrape_timeout() -> u64 {
389    15
390}
391
392fn default_max_body_bytes() -> usize {
393    4_194_304
394}
395
396/// Configuration for the web scrape tool.
397#[derive(Debug, Deserialize, Serialize)]
398pub struct ScrapeConfig {
399    #[serde(default = "default_scrape_timeout")]
400    pub timeout: u64,
401    #[serde(default = "default_max_body_bytes")]
402    pub max_body_bytes: usize,
403}
404
405impl Default for ScrapeConfig {
406    fn default() -> Self {
407        Self {
408            timeout: default_scrape_timeout(),
409            max_body_bytes: default_max_body_bytes(),
410        }
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn deserialize_default_config() {
420        let toml_str = r#"
421            enabled = true
422
423            [shell]
424            timeout = 60
425            blocked_commands = ["rm -rf /", "sudo"]
426        "#;
427
428        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
429        assert!(config.enabled);
430        assert_eq!(config.shell.timeout, 60);
431        assert_eq!(config.shell.blocked_commands.len(), 2);
432        assert_eq!(config.shell.blocked_commands[0], "rm -rf /");
433        assert_eq!(config.shell.blocked_commands[1], "sudo");
434    }
435
436    #[test]
437    fn empty_blocked_commands() {
438        let toml_str = r"
439            [shell]
440            timeout = 30
441        ";
442
443        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
444        assert!(config.enabled);
445        assert_eq!(config.shell.timeout, 30);
446        assert!(config.shell.blocked_commands.is_empty());
447    }
448
449    #[test]
450    fn default_tools_config() {
451        let config = ToolsConfig::default();
452        assert!(config.enabled);
453        assert!(config.summarize_output);
454        assert_eq!(config.shell.timeout, 30);
455        assert!(config.shell.blocked_commands.is_empty());
456        assert!(config.audit.enabled);
457    }
458
459    #[test]
460    fn tools_summarize_output_default_true() {
461        let config = ToolsConfig::default();
462        assert!(config.summarize_output);
463    }
464
465    #[test]
466    fn tools_summarize_output_parsing() {
467        let toml_str = r"
468            summarize_output = true
469        ";
470        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
471        assert!(config.summarize_output);
472    }
473
474    #[test]
475    fn default_shell_config() {
476        let config = ShellConfig::default();
477        assert_eq!(config.timeout, 30);
478        assert!(config.blocked_commands.is_empty());
479        assert!(config.allowed_paths.is_empty());
480        assert!(config.allow_network);
481        assert!(!config.confirm_patterns.is_empty());
482    }
483
484    #[test]
485    fn deserialize_omitted_fields_use_defaults() {
486        let toml_str = "";
487        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
488        assert!(config.enabled);
489        assert_eq!(config.shell.timeout, 30);
490        assert!(config.shell.blocked_commands.is_empty());
491        assert!(config.shell.allow_network);
492        assert!(!config.shell.confirm_patterns.is_empty());
493        assert_eq!(config.scrape.timeout, 15);
494        assert_eq!(config.scrape.max_body_bytes, 4_194_304);
495        assert!(config.audit.enabled);
496        assert_eq!(config.audit.destination, "stdout");
497        assert!(config.summarize_output);
498    }
499
500    #[test]
501    fn default_scrape_config() {
502        let config = ScrapeConfig::default();
503        assert_eq!(config.timeout, 15);
504        assert_eq!(config.max_body_bytes, 4_194_304);
505    }
506
507    #[test]
508    fn deserialize_scrape_config() {
509        let toml_str = r"
510            [scrape]
511            timeout = 30
512            max_body_bytes = 2097152
513        ";
514
515        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
516        assert_eq!(config.scrape.timeout, 30);
517        assert_eq!(config.scrape.max_body_bytes, 2_097_152);
518    }
519
520    #[test]
521    fn tools_config_default_includes_scrape() {
522        let config = ToolsConfig::default();
523        assert_eq!(config.scrape.timeout, 15);
524        assert_eq!(config.scrape.max_body_bytes, 4_194_304);
525    }
526
527    #[test]
528    fn deserialize_allowed_commands() {
529        let toml_str = r#"
530            [shell]
531            timeout = 30
532            allowed_commands = ["curl", "wget"]
533        "#;
534
535        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
536        assert_eq!(config.shell.allowed_commands, vec!["curl", "wget"]);
537    }
538
539    #[test]
540    fn default_allowed_commands_empty() {
541        let config = ShellConfig::default();
542        assert!(config.allowed_commands.is_empty());
543    }
544
545    #[test]
546    fn deserialize_shell_security_fields() {
547        let toml_str = r#"
548            [shell]
549            allowed_paths = ["/tmp", "/home/user"]
550            allow_network = false
551            confirm_patterns = ["rm ", "drop table"]
552        "#;
553
554        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
555        assert_eq!(config.shell.allowed_paths, vec!["/tmp", "/home/user"]);
556        assert!(!config.shell.allow_network);
557        assert_eq!(config.shell.confirm_patterns, vec!["rm ", "drop table"]);
558    }
559
560    #[test]
561    fn deserialize_audit_config() {
562        let toml_str = r#"
563            [audit]
564            enabled = true
565            destination = "/var/log/zeph-audit.log"
566        "#;
567
568        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
569        assert!(config.audit.enabled);
570        assert_eq!(config.audit.destination, "/var/log/zeph-audit.log");
571    }
572
573    #[test]
574    fn default_audit_config() {
575        let config = AuditConfig::default();
576        assert!(config.enabled);
577        assert_eq!(config.destination, "stdout");
578    }
579
580    #[test]
581    fn permission_policy_from_legacy_fields() {
582        let config = ToolsConfig {
583            shell: ShellConfig {
584                blocked_commands: vec!["sudo".to_owned()],
585                confirm_patterns: vec!["rm ".to_owned()],
586                ..ShellConfig::default()
587            },
588            ..ToolsConfig::default()
589        };
590        let policy = config.permission_policy(AutonomyLevel::Supervised);
591        assert_eq!(
592            policy.check("bash", "sudo apt"),
593            crate::permissions::PermissionAction::Deny
594        );
595        assert_eq!(
596            policy.check("bash", "rm file"),
597            crate::permissions::PermissionAction::Ask
598        );
599    }
600
601    #[test]
602    fn permission_policy_from_explicit_config() {
603        let toml_str = r#"
604            [permissions]
605            [[permissions.bash]]
606            pattern = "*sudo*"
607            action = "deny"
608        "#;
609        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
610        let policy = config.permission_policy(AutonomyLevel::Supervised);
611        assert_eq!(
612            policy.check("bash", "sudo rm"),
613            crate::permissions::PermissionAction::Deny
614        );
615    }
616
617    #[test]
618    fn permission_policy_default_uses_legacy() {
619        let config = ToolsConfig::default();
620        assert!(config.permissions.is_none());
621        let policy = config.permission_policy(AutonomyLevel::Supervised);
622        // Default ShellConfig has confirm_patterns, so legacy rules are generated
623        assert!(!config.shell.confirm_patterns.is_empty());
624        assert!(policy.rules().contains_key("bash"));
625    }
626
627    #[test]
628    fn deserialize_overflow_config_full() {
629        let toml_str = r"
630            [overflow]
631            threshold = 100000
632            retention_days = 14
633            max_overflow_bytes = 5242880
634        ";
635        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
636        assert_eq!(config.overflow.threshold, 100_000);
637        assert_eq!(config.overflow.retention_days, 14);
638        assert_eq!(config.overflow.max_overflow_bytes, 5_242_880);
639    }
640
641    #[test]
642    fn deserialize_overflow_config_unknown_dir_field_is_ignored() {
643        // Old configs with `dir = "..."` must not fail deserialization.
644        let toml_str = r#"
645            [overflow]
646            threshold = 75000
647            dir = "/tmp/overflow"
648        "#;
649        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
650        assert_eq!(config.overflow.threshold, 75_000);
651    }
652
653    #[test]
654    fn deserialize_overflow_config_partial_uses_defaults() {
655        let toml_str = r"
656            [overflow]
657            threshold = 75000
658        ";
659        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
660        assert_eq!(config.overflow.threshold, 75_000);
661        assert_eq!(config.overflow.retention_days, 7);
662    }
663
664    #[test]
665    fn deserialize_overflow_config_omitted_uses_defaults() {
666        let config: ToolsConfig = toml::from_str("").unwrap();
667        assert_eq!(config.overflow.threshold, 50_000);
668        assert_eq!(config.overflow.retention_days, 7);
669        assert_eq!(config.overflow.max_overflow_bytes, 10 * 1024 * 1024);
670    }
671
672    #[test]
673    fn result_cache_config_defaults() {
674        let config = ResultCacheConfig::default();
675        assert!(config.enabled);
676        assert_eq!(config.ttl_secs, 300);
677    }
678
679    #[test]
680    fn deserialize_result_cache_config() {
681        let toml_str = r"
682            [result_cache]
683            enabled = false
684            ttl_secs = 60
685        ";
686        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
687        assert!(!config.result_cache.enabled);
688        assert_eq!(config.result_cache.ttl_secs, 60);
689    }
690
691    #[test]
692    fn result_cache_omitted_uses_defaults() {
693        let config: ToolsConfig = toml::from_str("").unwrap();
694        assert!(config.result_cache.enabled);
695        assert_eq!(config.result_cache.ttl_secs, 300);
696    }
697
698    #[test]
699    fn result_cache_ttl_zero_is_valid() {
700        let toml_str = r"
701            [result_cache]
702            ttl_secs = 0
703        ";
704        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
705        assert_eq!(config.result_cache.ttl_secs, 0);
706    }
707}