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};
7use crate::policy::{PolicyConfig, PolicyRuleConfig};
8
9fn default_true() -> bool {
10    true
11}
12fn default_adversarial_timeout_ms() -> u64 {
13    3_000
14}
15
16fn default_timeout() -> u64 {
17    30
18}
19
20fn default_cache_ttl_secs() -> u64 {
21    300
22}
23
24fn default_confirm_patterns() -> Vec<String> {
25    vec![
26        "rm ".into(),
27        "git push -f".into(),
28        "git push --force".into(),
29        "drop table".into(),
30        "drop database".into(),
31        "truncate ".into(),
32        "$(".into(),
33        "`".into(),
34        "<(".into(),
35        ">(".into(),
36        "<<<".into(),
37        "eval ".into(),
38    ]
39}
40
41fn default_audit_destination() -> String {
42    "stdout".into()
43}
44
45fn default_overflow_threshold() -> usize {
46    50_000
47}
48
49fn default_retention_days() -> u64 {
50    7
51}
52
53fn default_max_overflow_bytes() -> usize {
54    10 * 1024 * 1024 // 10 MiB
55}
56
57/// Configuration for large tool response offload to `SQLite`.
58#[derive(Debug, Clone, Deserialize, Serialize)]
59pub struct OverflowConfig {
60    #[serde(default = "default_overflow_threshold")]
61    pub threshold: usize,
62    #[serde(default = "default_retention_days")]
63    pub retention_days: u64,
64    /// Maximum bytes per overflow entry. `0` means unlimited.
65    #[serde(default = "default_max_overflow_bytes")]
66    pub max_overflow_bytes: usize,
67}
68
69impl Default for OverflowConfig {
70    fn default() -> Self {
71        Self {
72            threshold: default_overflow_threshold(),
73            retention_days: default_retention_days(),
74            max_overflow_bytes: default_max_overflow_bytes(),
75        }
76    }
77}
78
79fn default_anomaly_window() -> usize {
80    10
81}
82
83fn default_anomaly_error_threshold() -> f64 {
84    0.5
85}
86
87fn default_anomaly_critical_threshold() -> f64 {
88    0.8
89}
90
91/// Configuration for the sliding-window anomaly detector.
92#[derive(Debug, Clone, Deserialize, Serialize)]
93pub struct AnomalyConfig {
94    #[serde(default = "default_true")]
95    pub enabled: bool,
96    #[serde(default = "default_anomaly_window")]
97    pub window_size: usize,
98    #[serde(default = "default_anomaly_error_threshold")]
99    pub error_threshold: f64,
100    #[serde(default = "default_anomaly_critical_threshold")]
101    pub critical_threshold: f64,
102    /// Emit a WARN log when a reasoning-enhanced model (o1, o3, `QwQ`, etc.) produces
103    /// a quality failure (`ToolNotFound`, `InvalidParameters`, `TypeMismatch`). Default: `true`.
104    ///
105    /// Based on arXiv:2510.22977 — CoT/RL reasoning amplifies tool hallucination.
106    #[serde(default = "default_true")]
107    pub reasoning_model_warning: bool,
108}
109
110impl Default for AnomalyConfig {
111    fn default() -> Self {
112        Self {
113            enabled: true,
114            window_size: default_anomaly_window(),
115            error_threshold: default_anomaly_error_threshold(),
116            critical_threshold: default_anomaly_critical_threshold(),
117            reasoning_model_warning: true,
118        }
119    }
120}
121
122/// Configuration for the tool result cache.
123#[derive(Debug, Clone, Deserialize, Serialize)]
124pub struct ResultCacheConfig {
125    /// Whether caching is enabled. Default: `true`.
126    #[serde(default = "default_true")]
127    pub enabled: bool,
128    /// Time-to-live in seconds. `0` means entries never expire. Default: `300`.
129    #[serde(default = "default_cache_ttl_secs")]
130    pub ttl_secs: u64,
131}
132
133impl Default for ResultCacheConfig {
134    fn default() -> Self {
135        Self {
136            enabled: true,
137            ttl_secs: default_cache_ttl_secs(),
138        }
139    }
140}
141
142fn default_tafc_complexity_threshold() -> f64 {
143    0.6
144}
145
146/// Configuration for Think-Augmented Function Calling (TAFC).
147#[derive(Debug, Clone, Deserialize, Serialize)]
148pub struct TafcConfig {
149    /// Enable TAFC schema augmentation (default: false).
150    #[serde(default)]
151    pub enabled: bool,
152    /// Complexity threshold tau in [0.0, 1.0]; tools with complexity >= tau are augmented.
153    /// Default: 0.6
154    #[serde(default = "default_tafc_complexity_threshold")]
155    pub complexity_threshold: f64,
156}
157
158impl Default for TafcConfig {
159    fn default() -> Self {
160        Self {
161            enabled: false,
162            complexity_threshold: default_tafc_complexity_threshold(),
163        }
164    }
165}
166
167impl TafcConfig {
168    /// Validate and clamp `complexity_threshold` to \[0.0, 1.0\]. Reset NaN/Infinity to 0.6.
169    #[must_use]
170    pub fn validated(mut self) -> Self {
171        if self.complexity_threshold.is_finite() {
172            self.complexity_threshold = self.complexity_threshold.clamp(0.0, 1.0);
173        } else {
174            self.complexity_threshold = 0.6;
175        }
176        self
177    }
178}
179
180fn default_utility_threshold() -> f32 {
181    0.1
182}
183
184fn default_utility_gain_weight() -> f32 {
185    1.0
186}
187
188fn default_utility_cost_weight() -> f32 {
189    0.5
190}
191
192fn default_utility_redundancy_weight() -> f32 {
193    0.3
194}
195
196fn default_utility_uncertainty_bonus() -> f32 {
197    0.2
198}
199
200/// Configuration for utility-guided tool dispatch (`[tools.utility]` TOML section).
201///
202/// Implements the utility gate from arXiv:2603.19896: each tool call is scored
203/// `U = gain_weight*gain - cost_weight*cost - redundancy_weight*redundancy + uncertainty_bonus*uncertainty`.
204/// Calls with `U < threshold` are skipped (fail-closed on scoring errors).
205#[derive(Debug, Clone, Deserialize, Serialize)]
206#[serde(default)]
207pub struct UtilityScoringConfig {
208    /// Enable utility-guided gating. Default: false (opt-in).
209    pub enabled: bool,
210    /// Minimum utility score required to execute a tool call. Default: 0.1.
211    #[serde(default = "default_utility_threshold")]
212    pub threshold: f32,
213    /// Weight for the estimated gain component. Must be >= 0. Default: 1.0.
214    #[serde(default = "default_utility_gain_weight")]
215    pub gain_weight: f32,
216    /// Weight for the step cost component. Must be >= 0. Default: 0.5.
217    #[serde(default = "default_utility_cost_weight")]
218    pub cost_weight: f32,
219    /// Weight for the redundancy penalty. Must be >= 0. Default: 0.3.
220    #[serde(default = "default_utility_redundancy_weight")]
221    pub redundancy_weight: f32,
222    /// Weight for the exploration bonus. Must be >= 0. Default: 0.2.
223    #[serde(default = "default_utility_uncertainty_bonus")]
224    pub uncertainty_bonus: f32,
225    /// Tool names that bypass the utility gate unconditionally (case-insensitive).
226    /// Auto-populated with file-read tools when `MagicDocs` is enabled. User-specified
227    /// entries are preserved and merged additively with any auto-populated names.
228    #[serde(default)]
229    pub exempt_tools: Vec<String>,
230}
231
232impl Default for UtilityScoringConfig {
233    fn default() -> Self {
234        Self {
235            enabled: false,
236            threshold: default_utility_threshold(),
237            gain_weight: default_utility_gain_weight(),
238            cost_weight: default_utility_cost_weight(),
239            redundancy_weight: default_utility_redundancy_weight(),
240            uncertainty_bonus: default_utility_uncertainty_bonus(),
241            exempt_tools: Vec::new(),
242        }
243    }
244}
245
246impl UtilityScoringConfig {
247    /// Validate that all weights and threshold are non-negative and finite.
248    ///
249    /// # Errors
250    ///
251    /// Returns a description of the first invalid field found.
252    pub fn validate(&self) -> Result<(), String> {
253        let fields = [
254            ("threshold", self.threshold),
255            ("gain_weight", self.gain_weight),
256            ("cost_weight", self.cost_weight),
257            ("redundancy_weight", self.redundancy_weight),
258            ("uncertainty_bonus", self.uncertainty_bonus),
259        ];
260        for (name, val) in fields {
261            if !val.is_finite() {
262                return Err(format!("[tools.utility] {name} must be finite, got {val}"));
263            }
264            if val < 0.0 {
265                return Err(format!("[tools.utility] {name} must be >= 0, got {val}"));
266            }
267        }
268        Ok(())
269    }
270}
271
272fn default_boost_per_dep() -> f32 {
273    0.15
274}
275
276fn default_max_total_boost() -> f32 {
277    0.2
278}
279
280/// Dependency specification for a single tool.
281#[derive(Debug, Clone, Default, Deserialize, Serialize)]
282pub struct ToolDependency {
283    /// Hard prerequisites: tool is hidden until ALL of these have completed successfully.
284    #[serde(default, skip_serializing_if = "Vec::is_empty")]
285    pub requires: Vec<String>,
286    /// Soft prerequisites: tool gets a similarity boost when these have completed.
287    #[serde(default, skip_serializing_if = "Vec::is_empty")]
288    pub prefers: Vec<String>,
289}
290
291/// Configuration for the tool dependency graph feature.
292#[derive(Debug, Clone, Deserialize, Serialize)]
293pub struct DependencyConfig {
294    /// Whether dependency gating is enabled. Default: false.
295    #[serde(default)]
296    pub enabled: bool,
297    /// Similarity boost added per satisfied `prefers` dependency. Default: 0.15.
298    #[serde(default = "default_boost_per_dep")]
299    pub boost_per_dep: f32,
300    /// Maximum total boost applied regardless of how many `prefers` deps are met. Default: 0.2.
301    #[serde(default = "default_max_total_boost")]
302    pub max_total_boost: f32,
303    /// Per-tool dependency rules. Key is `tool_id`.
304    #[serde(default)]
305    pub rules: std::collections::HashMap<String, ToolDependency>,
306}
307
308impl Default for DependencyConfig {
309    fn default() -> Self {
310        Self {
311            enabled: false,
312            boost_per_dep: default_boost_per_dep(),
313            max_total_boost: default_max_total_boost(),
314            rules: std::collections::HashMap::new(),
315        }
316    }
317}
318
319fn default_retry_max_attempts() -> usize {
320    2
321}
322
323fn default_retry_base_ms() -> u64 {
324    500
325}
326
327fn default_retry_max_ms() -> u64 {
328    5_000
329}
330
331fn default_retry_budget_secs() -> u64 {
332    30
333}
334
335/// Configuration for tool error retry behavior.
336#[derive(Debug, Clone, Deserialize, Serialize)]
337pub struct RetryConfig {
338    /// Maximum retry attempts for transient errors per tool call. 0 = disabled.
339    #[serde(default = "default_retry_max_attempts")]
340    pub max_attempts: usize,
341    /// Base delay (ms) for exponential backoff.
342    #[serde(default = "default_retry_base_ms")]
343    pub base_ms: u64,
344    /// Maximum delay cap (ms) for exponential backoff.
345    #[serde(default = "default_retry_max_ms")]
346    pub max_ms: u64,
347    /// Maximum wall-clock time (seconds) for all retries of a single tool call. 0 = unlimited.
348    #[serde(default = "default_retry_budget_secs")]
349    pub budget_secs: u64,
350    /// Provider name from `[[llm.providers]]` for LLM-based parameter reformatting on
351    /// `InvalidParameters`/`TypeMismatch` errors. Empty string = disabled.
352    #[serde(default)]
353    pub parameter_reformat_provider: String,
354}
355
356impl Default for RetryConfig {
357    fn default() -> Self {
358        Self {
359            max_attempts: default_retry_max_attempts(),
360            base_ms: default_retry_base_ms(),
361            max_ms: default_retry_max_ms(),
362            budget_secs: default_retry_budget_secs(),
363            parameter_reformat_provider: String::new(),
364        }
365    }
366}
367
368/// Configuration for the LLM-based adversarial policy agent.
369#[derive(Debug, Clone, Deserialize, Serialize)]
370pub struct AdversarialPolicyConfig {
371    /// Enable the adversarial policy agent. Default: `false`.
372    #[serde(default)]
373    pub enabled: bool,
374    /// Provider name from `[[llm.providers]]` for the policy validation LLM.
375    /// Should reference a fast, cheap model (e.g. `gpt-4o-mini`).
376    /// Empty string = fall back to the default provider.
377    #[serde(default)]
378    pub policy_provider: String,
379    /// Path to a plain-text policy file. Each non-empty, non-comment line is one policy.
380    pub policy_file: Option<String>,
381    /// Whether to allow tool calls when the policy LLM fails (timeout/error).
382    /// Default: `false` (fail-closed / deny on error).
383    ///
384    /// Setting this to `true` trades security for availability. Use only in
385    /// deployments where the declarative `PolicyEnforcer` already covers hard rules.
386    #[serde(default)]
387    pub fail_open: bool,
388    /// Timeout in milliseconds for a single policy LLM call. Default: 3000.
389    #[serde(default = "default_adversarial_timeout_ms")]
390    pub timeout_ms: u64,
391    /// Tool names that are always allowed through the adversarial policy gate,
392    /// regardless of policy content. Covers internal agent operations that are
393    /// not externally visible side effects.
394    #[serde(default = "AdversarialPolicyConfig::default_exempt_tools")]
395    pub exempt_tools: Vec<String>,
396}
397impl Default for AdversarialPolicyConfig {
398    fn default() -> Self {
399        Self {
400            enabled: false,
401            policy_provider: String::new(),
402            policy_file: None,
403            fail_open: false,
404            timeout_ms: default_adversarial_timeout_ms(),
405            exempt_tools: Self::default_exempt_tools(),
406        }
407    }
408}
409impl AdversarialPolicyConfig {
410    fn default_exempt_tools() -> Vec<String> {
411        vec![
412            "memory_save".into(),
413            "memory_search".into(),
414            "read_overflow".into(),
415            "load_skill".into(),
416            "schedule_deferred".into(),
417        ]
418    }
419}
420
421/// Per-path read allow/deny sandbox for the file tool.
422///
423/// Evaluation order: deny-then-allow. If a path matches `deny_read` and does NOT
424/// match `allow_read`, access is denied. Empty `deny_read` means no read restrictions.
425///
426/// All patterns are matched against the canonicalized (absolute, symlink-resolved) path.
427#[derive(Debug, Clone, Default, Deserialize, Serialize)]
428pub struct FileConfig {
429    /// Glob patterns for paths denied for reading. Evaluated first.
430    #[serde(default)]
431    pub deny_read: Vec<String>,
432    /// Glob patterns for paths allowed for reading. Evaluated second (overrides deny).
433    #[serde(default)]
434    pub allow_read: Vec<String>,
435}
436
437/// Top-level configuration for tool execution.
438#[derive(Debug, Deserialize, Serialize)]
439pub struct ToolsConfig {
440    #[serde(default = "default_true")]
441    pub enabled: bool,
442    #[serde(default = "default_true")]
443    pub summarize_output: bool,
444    #[serde(default)]
445    pub shell: ShellConfig,
446    #[serde(default)]
447    pub scrape: ScrapeConfig,
448    #[serde(default)]
449    pub audit: AuditConfig,
450    #[serde(default)]
451    pub permissions: Option<PermissionsConfig>,
452    #[serde(default)]
453    pub filters: crate::filter::FilterConfig,
454    #[serde(default)]
455    pub overflow: OverflowConfig,
456    #[serde(default)]
457    pub anomaly: AnomalyConfig,
458    #[serde(default)]
459    pub result_cache: ResultCacheConfig,
460    #[serde(default)]
461    pub tafc: TafcConfig,
462    #[serde(default)]
463    pub dependencies: DependencyConfig,
464    #[serde(default)]
465    pub retry: RetryConfig,
466    /// Declarative policy compiler for tool call authorization.
467    #[serde(default)]
468    pub policy: PolicyConfig,
469    /// LLM-based adversarial policy agent for natural-language policy enforcement.
470    #[serde(default)]
471    pub adversarial_policy: AdversarialPolicyConfig,
472    /// Utility-guided tool dispatch gate.
473    #[serde(default)]
474    pub utility: UtilityScoringConfig,
475    /// Per-path read allow/deny sandbox for the file tool.
476    #[serde(default)]
477    pub file: FileConfig,
478    /// OAP declarative pre-action authorization. Rules are merged into `PolicyEnforcer` at
479    /// startup. Authorization rules are appended after `policy.rules` — policy rules take
480    /// precedence (first-match-wins semantics). This means existing policy allow/deny rules
481    /// are evaluated before authorization rules.
482    #[serde(default)]
483    pub authorization: AuthorizationConfig,
484    /// Maximum tool calls allowed per agent session. `None` = unlimited (default).
485    /// Counted on the first attempt only — retries do not consume additional quota slots.
486    #[serde(default)]
487    pub max_tool_calls_per_session: Option<u32>,
488}
489
490impl ToolsConfig {
491    /// Build a `PermissionPolicy` from explicit config or legacy shell fields.
492    #[must_use]
493    pub fn permission_policy(&self, autonomy_level: AutonomyLevel) -> PermissionPolicy {
494        let policy = if let Some(ref perms) = self.permissions {
495            PermissionPolicy::from(perms.clone())
496        } else {
497            PermissionPolicy::from_legacy(
498                &self.shell.blocked_commands,
499                &self.shell.confirm_patterns,
500            )
501        };
502        policy.with_autonomy(autonomy_level)
503    }
504}
505
506/// Shell-specific configuration: timeout, command blocklist, and allowlist overrides.
507#[derive(Debug, Deserialize, Serialize)]
508#[allow(clippy::struct_excessive_bools)]
509pub struct ShellConfig {
510    #[serde(default = "default_timeout")]
511    pub timeout: u64,
512    #[serde(default)]
513    pub blocked_commands: Vec<String>,
514    #[serde(default)]
515    pub allowed_commands: Vec<String>,
516    #[serde(default)]
517    pub allowed_paths: Vec<String>,
518    #[serde(default = "default_true")]
519    pub allow_network: bool,
520    #[serde(default = "default_confirm_patterns")]
521    pub confirm_patterns: Vec<String>,
522    /// Environment variable name prefixes to strip from subprocess environment.
523    /// Variables whose names start with any of these prefixes are removed before
524    /// spawning shell commands. Default covers common credential naming conventions.
525    #[serde(default = "ShellConfig::default_env_blocklist")]
526    pub env_blocklist: Vec<String>,
527    /// Enable transactional mode: snapshot files before write commands, rollback on failure.
528    #[serde(default)]
529    pub transactional: bool,
530    /// Glob patterns defining which paths are eligible for snapshotting.
531    /// Only files matching these patterns (relative to cwd) are captured.
532    /// Empty = snapshot all files referenced in the command.
533    #[serde(default)]
534    pub transaction_scope: Vec<String>,
535    /// Automatically rollback when exit code >= 2. Default: false.
536    /// Exit code 1 is excluded because many tools (grep, diff, test) use it for
537    /// non-error conditions.
538    #[serde(default)]
539    pub auto_rollback: bool,
540    /// Exit codes that trigger auto-rollback. Default: empty (uses >= 2 heuristic).
541    /// When non-empty, only these exact exit codes trigger rollback.
542    #[serde(default)]
543    pub auto_rollback_exit_codes: Vec<i32>,
544    /// When true, snapshot failure aborts execution with an error.
545    /// When false (default), snapshot failure emits a warning and execution proceeds.
546    #[serde(default)]
547    pub snapshot_required: bool,
548    /// Maximum cumulative bytes for transaction snapshots. 0 = unlimited.
549    #[serde(default)]
550    pub max_snapshot_bytes: u64,
551}
552
553impl ShellConfig {
554    #[must_use]
555    pub fn default_env_blocklist() -> Vec<String> {
556        vec![
557            "ZEPH_".into(),
558            "AWS_".into(),
559            "AZURE_".into(),
560            "GCP_".into(),
561            "GOOGLE_".into(),
562            "OPENAI_".into(),
563            "ANTHROPIC_".into(),
564            "HF_".into(),
565            "HUGGING".into(),
566        ]
567    }
568}
569
570/// Configuration for audit logging of tool executions.
571#[derive(Debug, Deserialize, Serialize)]
572pub struct AuditConfig {
573    #[serde(default = "default_true")]
574    pub enabled: bool,
575    #[serde(default = "default_audit_destination")]
576    pub destination: String,
577    /// When true, log a per-tool risk summary at startup.
578    /// Each entry includes: tool name, privilege level, and expected input sanitization.
579    /// This is a design-time risk inventory, NOT runtime static analysis or a guarantee
580    /// that sanitization is functioning correctly.
581    #[serde(default)]
582    pub tool_risk_summary: bool,
583}
584
585impl Default for ToolsConfig {
586    fn default() -> Self {
587        Self {
588            enabled: true,
589            summarize_output: true,
590            shell: ShellConfig::default(),
591            scrape: ScrapeConfig::default(),
592            audit: AuditConfig::default(),
593            permissions: None,
594            filters: crate::filter::FilterConfig::default(),
595            overflow: OverflowConfig::default(),
596            anomaly: AnomalyConfig::default(),
597            result_cache: ResultCacheConfig::default(),
598            tafc: TafcConfig::default(),
599            dependencies: DependencyConfig::default(),
600            retry: RetryConfig::default(),
601            policy: PolicyConfig::default(),
602            adversarial_policy: AdversarialPolicyConfig::default(),
603            utility: UtilityScoringConfig::default(),
604            file: FileConfig::default(),
605            authorization: AuthorizationConfig::default(),
606            max_tool_calls_per_session: None,
607        }
608    }
609}
610
611impl Default for ShellConfig {
612    fn default() -> Self {
613        Self {
614            timeout: default_timeout(),
615            blocked_commands: Vec::new(),
616            allowed_commands: Vec::new(),
617            allowed_paths: Vec::new(),
618            allow_network: true,
619            confirm_patterns: default_confirm_patterns(),
620            env_blocklist: Self::default_env_blocklist(),
621            transactional: false,
622            transaction_scope: Vec::new(),
623            auto_rollback: false,
624            auto_rollback_exit_codes: Vec::new(),
625            snapshot_required: false,
626            max_snapshot_bytes: 0,
627        }
628    }
629}
630
631impl Default for AuditConfig {
632    fn default() -> Self {
633        Self {
634            enabled: true,
635            destination: default_audit_destination(),
636            tool_risk_summary: false,
637        }
638    }
639}
640
641/// OAP-style declarative authorization. Rules are merged into `PolicyEnforcer` at startup.
642///
643/// Precedence: `policy.rules` are evaluated first (first-match-wins), then `authorization.rules`.
644/// Use `[tools.policy]` for deny-wins safety rules; use `[tools.authorization]` for
645/// capability-based allow/deny rules that layer on top.
646#[derive(Debug, Clone, Default, Deserialize, Serialize)]
647pub struct AuthorizationConfig {
648    /// Enable OAP authorization checks. When false, `rules` are ignored. Default: false.
649    #[serde(default)]
650    pub enabled: bool,
651    /// Per-tool authorization rules. Appended after `[tools.policy]` rules at startup.
652    #[serde(default)]
653    pub rules: Vec<PolicyRuleConfig>,
654}
655
656fn default_scrape_timeout() -> u64 {
657    15
658}
659
660fn default_max_body_bytes() -> usize {
661    4_194_304
662}
663
664/// Configuration for the web scrape tool.
665#[derive(Debug, Deserialize, Serialize)]
666pub struct ScrapeConfig {
667    #[serde(default = "default_scrape_timeout")]
668    pub timeout: u64,
669    #[serde(default = "default_max_body_bytes")]
670    pub max_body_bytes: usize,
671    /// Domain allowlist. Empty = all public domains allowed (default, existing behavior).
672    /// When non-empty, ONLY URLs whose host matches an entry are permitted (deny-unknown).
673    /// Supports exact match (`"docs.rs"`) and wildcard prefix (`"*.rust-lang.org"`).
674    /// Wildcard `*` matches a single subdomain segment only.
675    ///
676    /// Operators SHOULD set an explicit allowlist in production deployments.
677    /// Empty allowlist with a non-empty `denied_domains` is a denylist-only configuration
678    /// which is NOT a security boundary — an attacker can use any domain not on the list.
679    #[serde(default)]
680    pub allowed_domains: Vec<String>,
681    /// Domain denylist. Always enforced, regardless of allowlist state.
682    /// Supports the same pattern syntax as `allowed_domains`.
683    #[serde(default)]
684    pub denied_domains: Vec<String>,
685}
686
687impl Default for ScrapeConfig {
688    fn default() -> Self {
689        Self {
690            timeout: default_scrape_timeout(),
691            max_body_bytes: default_max_body_bytes(),
692            allowed_domains: Vec::new(),
693            denied_domains: Vec::new(),
694        }
695    }
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701
702    #[test]
703    fn deserialize_default_config() {
704        let toml_str = r#"
705            enabled = true
706
707            [shell]
708            timeout = 60
709            blocked_commands = ["rm -rf /", "sudo"]
710        "#;
711
712        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
713        assert!(config.enabled);
714        assert_eq!(config.shell.timeout, 60);
715        assert_eq!(config.shell.blocked_commands.len(), 2);
716        assert_eq!(config.shell.blocked_commands[0], "rm -rf /");
717        assert_eq!(config.shell.blocked_commands[1], "sudo");
718    }
719
720    #[test]
721    fn empty_blocked_commands() {
722        let toml_str = r"
723            [shell]
724            timeout = 30
725        ";
726
727        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
728        assert!(config.enabled);
729        assert_eq!(config.shell.timeout, 30);
730        assert!(config.shell.blocked_commands.is_empty());
731    }
732
733    #[test]
734    fn default_tools_config() {
735        let config = ToolsConfig::default();
736        assert!(config.enabled);
737        assert!(config.summarize_output);
738        assert_eq!(config.shell.timeout, 30);
739        assert!(config.shell.blocked_commands.is_empty());
740        assert!(config.audit.enabled);
741    }
742
743    #[test]
744    fn tools_summarize_output_default_true() {
745        let config = ToolsConfig::default();
746        assert!(config.summarize_output);
747    }
748
749    #[test]
750    fn tools_summarize_output_parsing() {
751        let toml_str = r"
752            summarize_output = true
753        ";
754        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
755        assert!(config.summarize_output);
756    }
757
758    #[test]
759    fn default_shell_config() {
760        let config = ShellConfig::default();
761        assert_eq!(config.timeout, 30);
762        assert!(config.blocked_commands.is_empty());
763        assert!(config.allowed_paths.is_empty());
764        assert!(config.allow_network);
765        assert!(!config.confirm_patterns.is_empty());
766    }
767
768    #[test]
769    fn deserialize_omitted_fields_use_defaults() {
770        let toml_str = "";
771        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
772        assert!(config.enabled);
773        assert_eq!(config.shell.timeout, 30);
774        assert!(config.shell.blocked_commands.is_empty());
775        assert!(config.shell.allow_network);
776        assert!(!config.shell.confirm_patterns.is_empty());
777        assert_eq!(config.scrape.timeout, 15);
778        assert_eq!(config.scrape.max_body_bytes, 4_194_304);
779        assert!(config.audit.enabled);
780        assert_eq!(config.audit.destination, "stdout");
781        assert!(config.summarize_output);
782    }
783
784    #[test]
785    fn default_scrape_config() {
786        let config = ScrapeConfig::default();
787        assert_eq!(config.timeout, 15);
788        assert_eq!(config.max_body_bytes, 4_194_304);
789    }
790
791    #[test]
792    fn deserialize_scrape_config() {
793        let toml_str = r"
794            [scrape]
795            timeout = 30
796            max_body_bytes = 2097152
797        ";
798
799        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
800        assert_eq!(config.scrape.timeout, 30);
801        assert_eq!(config.scrape.max_body_bytes, 2_097_152);
802    }
803
804    #[test]
805    fn tools_config_default_includes_scrape() {
806        let config = ToolsConfig::default();
807        assert_eq!(config.scrape.timeout, 15);
808        assert_eq!(config.scrape.max_body_bytes, 4_194_304);
809    }
810
811    #[test]
812    fn deserialize_allowed_commands() {
813        let toml_str = r#"
814            [shell]
815            timeout = 30
816            allowed_commands = ["curl", "wget"]
817        "#;
818
819        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
820        assert_eq!(config.shell.allowed_commands, vec!["curl", "wget"]);
821    }
822
823    #[test]
824    fn default_allowed_commands_empty() {
825        let config = ShellConfig::default();
826        assert!(config.allowed_commands.is_empty());
827    }
828
829    #[test]
830    fn deserialize_shell_security_fields() {
831        let toml_str = r#"
832            [shell]
833            allowed_paths = ["/tmp", "/home/user"]
834            allow_network = false
835            confirm_patterns = ["rm ", "drop table"]
836        "#;
837
838        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
839        assert_eq!(config.shell.allowed_paths, vec!["/tmp", "/home/user"]);
840        assert!(!config.shell.allow_network);
841        assert_eq!(config.shell.confirm_patterns, vec!["rm ", "drop table"]);
842    }
843
844    #[test]
845    fn deserialize_audit_config() {
846        let toml_str = r#"
847            [audit]
848            enabled = true
849            destination = "/var/log/zeph-audit.log"
850        "#;
851
852        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
853        assert!(config.audit.enabled);
854        assert_eq!(config.audit.destination, "/var/log/zeph-audit.log");
855    }
856
857    #[test]
858    fn default_audit_config() {
859        let config = AuditConfig::default();
860        assert!(config.enabled);
861        assert_eq!(config.destination, "stdout");
862    }
863
864    #[test]
865    fn permission_policy_from_legacy_fields() {
866        let config = ToolsConfig {
867            shell: ShellConfig {
868                blocked_commands: vec!["sudo".to_owned()],
869                confirm_patterns: vec!["rm ".to_owned()],
870                ..ShellConfig::default()
871            },
872            ..ToolsConfig::default()
873        };
874        let policy = config.permission_policy(AutonomyLevel::Supervised);
875        assert_eq!(
876            policy.check("bash", "sudo apt"),
877            crate::permissions::PermissionAction::Deny
878        );
879        assert_eq!(
880            policy.check("bash", "rm file"),
881            crate::permissions::PermissionAction::Ask
882        );
883    }
884
885    #[test]
886    fn permission_policy_from_explicit_config() {
887        let toml_str = r#"
888            [permissions]
889            [[permissions.bash]]
890            pattern = "*sudo*"
891            action = "deny"
892        "#;
893        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
894        let policy = config.permission_policy(AutonomyLevel::Supervised);
895        assert_eq!(
896            policy.check("bash", "sudo rm"),
897            crate::permissions::PermissionAction::Deny
898        );
899    }
900
901    #[test]
902    fn permission_policy_default_uses_legacy() {
903        let config = ToolsConfig::default();
904        assert!(config.permissions.is_none());
905        let policy = config.permission_policy(AutonomyLevel::Supervised);
906        // Default ShellConfig has confirm_patterns, so legacy rules are generated
907        assert!(!config.shell.confirm_patterns.is_empty());
908        assert!(policy.rules().contains_key("bash"));
909    }
910
911    #[test]
912    fn deserialize_overflow_config_full() {
913        let toml_str = r"
914            [overflow]
915            threshold = 100000
916            retention_days = 14
917            max_overflow_bytes = 5242880
918        ";
919        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
920        assert_eq!(config.overflow.threshold, 100_000);
921        assert_eq!(config.overflow.retention_days, 14);
922        assert_eq!(config.overflow.max_overflow_bytes, 5_242_880);
923    }
924
925    #[test]
926    fn deserialize_overflow_config_unknown_dir_field_is_ignored() {
927        // Old configs with `dir = "..."` must not fail deserialization.
928        let toml_str = r#"
929            [overflow]
930            threshold = 75000
931            dir = "/tmp/overflow"
932        "#;
933        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
934        assert_eq!(config.overflow.threshold, 75_000);
935    }
936
937    #[test]
938    fn deserialize_overflow_config_partial_uses_defaults() {
939        let toml_str = r"
940            [overflow]
941            threshold = 75000
942        ";
943        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
944        assert_eq!(config.overflow.threshold, 75_000);
945        assert_eq!(config.overflow.retention_days, 7);
946    }
947
948    #[test]
949    fn deserialize_overflow_config_omitted_uses_defaults() {
950        let config: ToolsConfig = toml::from_str("").unwrap();
951        assert_eq!(config.overflow.threshold, 50_000);
952        assert_eq!(config.overflow.retention_days, 7);
953        assert_eq!(config.overflow.max_overflow_bytes, 10 * 1024 * 1024);
954    }
955
956    #[test]
957    fn result_cache_config_defaults() {
958        let config = ResultCacheConfig::default();
959        assert!(config.enabled);
960        assert_eq!(config.ttl_secs, 300);
961    }
962
963    #[test]
964    fn deserialize_result_cache_config() {
965        let toml_str = r"
966            [result_cache]
967            enabled = false
968            ttl_secs = 60
969        ";
970        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
971        assert!(!config.result_cache.enabled);
972        assert_eq!(config.result_cache.ttl_secs, 60);
973    }
974
975    #[test]
976    fn result_cache_omitted_uses_defaults() {
977        let config: ToolsConfig = toml::from_str("").unwrap();
978        assert!(config.result_cache.enabled);
979        assert_eq!(config.result_cache.ttl_secs, 300);
980    }
981
982    #[test]
983    fn result_cache_ttl_zero_is_valid() {
984        let toml_str = r"
985            [result_cache]
986            ttl_secs = 0
987        ";
988        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
989        assert_eq!(config.result_cache.ttl_secs, 0);
990    }
991}