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_max_background_runs() -> usize {
21    8
22}
23
24fn default_background_timeout_secs() -> u64 {
25    1800
26}
27
28fn default_cache_ttl_secs() -> u64 {
29    300
30}
31
32fn default_confirm_patterns() -> Vec<String> {
33    vec![
34        "rm ".into(),
35        "git push -f".into(),
36        "git push --force".into(),
37        "drop table".into(),
38        "drop database".into(),
39        "truncate ".into(),
40        "$(".into(),
41        "`".into(),
42        "<(".into(),
43        ">(".into(),
44        "<<<".into(),
45        "eval ".into(),
46    ]
47}
48
49fn default_audit_destination() -> String {
50    "stdout".into()
51}
52
53fn default_overflow_threshold() -> usize {
54    50_000
55}
56
57fn default_retention_days() -> u64 {
58    7
59}
60
61fn default_max_overflow_bytes() -> usize {
62    10 * 1024 * 1024 // 10 MiB
63}
64
65/// Configuration for large tool response offload to `SQLite`.
66#[derive(Debug, Clone, Deserialize, Serialize)]
67pub struct OverflowConfig {
68    #[serde(default = "default_overflow_threshold")]
69    pub threshold: usize,
70    #[serde(default = "default_retention_days")]
71    pub retention_days: u64,
72    /// Maximum bytes per overflow entry. `0` means unlimited.
73    #[serde(default = "default_max_overflow_bytes")]
74    pub max_overflow_bytes: usize,
75}
76
77impl Default for OverflowConfig {
78    fn default() -> Self {
79        Self {
80            threshold: default_overflow_threshold(),
81            retention_days: default_retention_days(),
82            max_overflow_bytes: default_max_overflow_bytes(),
83        }
84    }
85}
86
87fn default_anomaly_window() -> usize {
88    10
89}
90
91fn default_anomaly_error_threshold() -> f64 {
92    0.5
93}
94
95fn default_anomaly_critical_threshold() -> f64 {
96    0.8
97}
98
99/// Configuration for the sliding-window anomaly detector.
100#[derive(Debug, Clone, Deserialize, Serialize)]
101pub struct AnomalyConfig {
102    #[serde(default = "default_true")]
103    pub enabled: bool,
104    #[serde(default = "default_anomaly_window")]
105    pub window_size: usize,
106    #[serde(default = "default_anomaly_error_threshold")]
107    pub error_threshold: f64,
108    #[serde(default = "default_anomaly_critical_threshold")]
109    pub critical_threshold: f64,
110    /// Emit a WARN log when a reasoning-enhanced model (o1, o3, `QwQ`, etc.) produces
111    /// a quality failure (`ToolNotFound`, `InvalidParameters`, `TypeMismatch`). Default: `true`.
112    ///
113    /// Based on arXiv:2510.22977 — CoT/RL reasoning amplifies tool hallucination.
114    #[serde(default = "default_true")]
115    pub reasoning_model_warning: bool,
116}
117
118impl Default for AnomalyConfig {
119    fn default() -> Self {
120        Self {
121            enabled: true,
122            window_size: default_anomaly_window(),
123            error_threshold: default_anomaly_error_threshold(),
124            critical_threshold: default_anomaly_critical_threshold(),
125            reasoning_model_warning: true,
126        }
127    }
128}
129
130/// Configuration for the tool result cache.
131#[derive(Debug, Clone, Deserialize, Serialize)]
132pub struct ResultCacheConfig {
133    /// Whether caching is enabled. Default: `true`.
134    #[serde(default = "default_true")]
135    pub enabled: bool,
136    /// Time-to-live in seconds. `0` means entries never expire. Default: `300`.
137    #[serde(default = "default_cache_ttl_secs")]
138    pub ttl_secs: u64,
139}
140
141impl Default for ResultCacheConfig {
142    fn default() -> Self {
143        Self {
144            enabled: true,
145            ttl_secs: default_cache_ttl_secs(),
146        }
147    }
148}
149
150fn default_tafc_complexity_threshold() -> f64 {
151    0.6
152}
153
154/// Configuration for Think-Augmented Function Calling (TAFC).
155#[derive(Debug, Clone, Deserialize, Serialize)]
156pub struct TafcConfig {
157    /// Enable TAFC schema augmentation (default: false).
158    #[serde(default)]
159    pub enabled: bool,
160    /// Complexity threshold tau in [0.0, 1.0]; tools with complexity >= tau are augmented.
161    /// Default: 0.6
162    #[serde(default = "default_tafc_complexity_threshold")]
163    pub complexity_threshold: f64,
164}
165
166impl Default for TafcConfig {
167    fn default() -> Self {
168        Self {
169            enabled: false,
170            complexity_threshold: default_tafc_complexity_threshold(),
171        }
172    }
173}
174
175impl TafcConfig {
176    /// Validate and clamp `complexity_threshold` to \[0.0, 1.0\]. Reset NaN/Infinity to 0.6.
177    #[must_use]
178    pub fn validated(mut self) -> Self {
179        if self.complexity_threshold.is_finite() {
180            self.complexity_threshold = self.complexity_threshold.clamp(0.0, 1.0);
181        } else {
182            self.complexity_threshold = 0.6;
183        }
184        self
185    }
186}
187
188fn default_utility_exempt_tools() -> Vec<String> {
189    vec!["invoke_skill".to_string(), "load_skill".to_string()]
190}
191
192fn default_utility_threshold() -> f32 {
193    0.1
194}
195
196fn default_utility_gain_weight() -> f32 {
197    1.0
198}
199
200fn default_utility_cost_weight() -> f32 {
201    0.5
202}
203
204fn default_utility_redundancy_weight() -> f32 {
205    0.3
206}
207
208fn default_utility_uncertainty_bonus() -> f32 {
209    0.2
210}
211
212/// Configuration for utility-guided tool dispatch (`[tools.utility]` TOML section).
213///
214/// Implements the utility gate from arXiv:2603.19896: each tool call is scored
215/// `U = gain_weight*gain - cost_weight*cost - redundancy_weight*redundancy + uncertainty_bonus*uncertainty`.
216/// Calls with `U < threshold` are skipped (fail-closed on scoring errors).
217#[derive(Debug, Clone, Deserialize, Serialize)]
218#[serde(default)]
219pub struct UtilityScoringConfig {
220    /// Enable utility-guided gating. Default: false (opt-in).
221    pub enabled: bool,
222    /// Minimum utility score required to execute a tool call. Default: 0.1.
223    #[serde(default = "default_utility_threshold")]
224    pub threshold: f32,
225    /// Weight for the estimated gain component. Must be >= 0. Default: 1.0.
226    #[serde(default = "default_utility_gain_weight")]
227    pub gain_weight: f32,
228    /// Weight for the step cost component. Must be >= 0. Default: 0.5.
229    #[serde(default = "default_utility_cost_weight")]
230    pub cost_weight: f32,
231    /// Weight for the redundancy penalty. Must be >= 0. Default: 0.3.
232    #[serde(default = "default_utility_redundancy_weight")]
233    pub redundancy_weight: f32,
234    /// Weight for the exploration bonus. Must be >= 0. Default: 0.2.
235    #[serde(default = "default_utility_uncertainty_bonus")]
236    pub uncertainty_bonus: f32,
237    /// Tool names that bypass the utility gate unconditionally (case-insensitive).
238    /// Auto-populated with file-read tools when `MagicDocs` is enabled. User-specified
239    /// entries are preserved and merged additively with any auto-populated names.
240    #[serde(default = "default_utility_exempt_tools")]
241    pub exempt_tools: Vec<String>,
242}
243
244impl Default for UtilityScoringConfig {
245    fn default() -> Self {
246        Self {
247            enabled: false,
248            threshold: default_utility_threshold(),
249            gain_weight: default_utility_gain_weight(),
250            cost_weight: default_utility_cost_weight(),
251            redundancy_weight: default_utility_redundancy_weight(),
252            uncertainty_bonus: default_utility_uncertainty_bonus(),
253            exempt_tools: default_utility_exempt_tools(),
254        }
255    }
256}
257
258impl UtilityScoringConfig {
259    /// Validate that all weights and threshold are non-negative and finite.
260    ///
261    /// # Errors
262    ///
263    /// Returns a description of the first invalid field found.
264    pub fn validate(&self) -> Result<(), String> {
265        let fields = [
266            ("threshold", self.threshold),
267            ("gain_weight", self.gain_weight),
268            ("cost_weight", self.cost_weight),
269            ("redundancy_weight", self.redundancy_weight),
270            ("uncertainty_bonus", self.uncertainty_bonus),
271        ];
272        for (name, val) in fields {
273            if !val.is_finite() {
274                return Err(format!("[tools.utility] {name} must be finite, got {val}"));
275            }
276            if val < 0.0 {
277                return Err(format!("[tools.utility] {name} must be >= 0, got {val}"));
278            }
279        }
280        Ok(())
281    }
282}
283
284fn default_boost_per_dep() -> f32 {
285    0.15
286}
287
288fn default_max_total_boost() -> f32 {
289    0.2
290}
291
292/// Dependency specification for a single tool.
293#[derive(Debug, Clone, Default, Deserialize, Serialize)]
294pub struct ToolDependency {
295    /// Hard prerequisites: tool is hidden until ALL of these have completed successfully.
296    #[serde(default, skip_serializing_if = "Vec::is_empty")]
297    pub requires: Vec<String>,
298    /// Soft prerequisites: tool gets a similarity boost when these have completed.
299    #[serde(default, skip_serializing_if = "Vec::is_empty")]
300    pub prefers: Vec<String>,
301}
302
303/// Configuration for the tool dependency graph feature.
304#[derive(Debug, Clone, Deserialize, Serialize)]
305pub struct DependencyConfig {
306    /// Whether dependency gating is enabled. Default: false.
307    #[serde(default)]
308    pub enabled: bool,
309    /// Similarity boost added per satisfied `prefers` dependency. Default: 0.15.
310    #[serde(default = "default_boost_per_dep")]
311    pub boost_per_dep: f32,
312    /// Maximum total boost applied regardless of how many `prefers` deps are met. Default: 0.2.
313    #[serde(default = "default_max_total_boost")]
314    pub max_total_boost: f32,
315    /// Per-tool dependency rules. Key is `tool_id`.
316    #[serde(default)]
317    pub rules: std::collections::HashMap<String, ToolDependency>,
318}
319
320impl Default for DependencyConfig {
321    fn default() -> Self {
322        Self {
323            enabled: false,
324            boost_per_dep: default_boost_per_dep(),
325            max_total_boost: default_max_total_boost(),
326            rules: std::collections::HashMap::new(),
327        }
328    }
329}
330
331fn default_retry_max_attempts() -> usize {
332    2
333}
334
335fn default_retry_base_ms() -> u64 {
336    500
337}
338
339fn default_retry_max_ms() -> u64 {
340    5_000
341}
342
343fn default_retry_budget_secs() -> u64 {
344    30
345}
346
347/// Configuration for tool error retry behavior.
348#[derive(Debug, Clone, Deserialize, Serialize)]
349pub struct RetryConfig {
350    /// Maximum retry attempts for transient errors per tool call. 0 = disabled.
351    #[serde(default = "default_retry_max_attempts")]
352    pub max_attempts: usize,
353    /// Base delay (ms) for exponential backoff.
354    #[serde(default = "default_retry_base_ms")]
355    pub base_ms: u64,
356    /// Maximum delay cap (ms) for exponential backoff.
357    #[serde(default = "default_retry_max_ms")]
358    pub max_ms: u64,
359    /// Maximum wall-clock time (seconds) for all retries of a single tool call. 0 = unlimited.
360    #[serde(default = "default_retry_budget_secs")]
361    pub budget_secs: u64,
362    /// Provider name from `[[llm.providers]]` for LLM-based parameter reformatting on
363    /// `InvalidParameters`/`TypeMismatch` errors. Empty string = disabled.
364    #[serde(default)]
365    pub parameter_reformat_provider: String,
366}
367
368impl Default for RetryConfig {
369    fn default() -> Self {
370        Self {
371            max_attempts: default_retry_max_attempts(),
372            base_ms: default_retry_base_ms(),
373            max_ms: default_retry_max_ms(),
374            budget_secs: default_retry_budget_secs(),
375            parameter_reformat_provider: String::new(),
376        }
377    }
378}
379
380/// Configuration for the LLM-based adversarial policy agent.
381#[derive(Debug, Clone, Deserialize, Serialize)]
382pub struct AdversarialPolicyConfig {
383    /// Enable the adversarial policy agent. Default: `false`.
384    #[serde(default)]
385    pub enabled: bool,
386    /// Provider name from `[[llm.providers]]` for the policy validation LLM.
387    /// Should reference a fast, cheap model (e.g. `gpt-4o-mini`).
388    /// Empty string = fall back to the default provider.
389    #[serde(default)]
390    pub policy_provider: String,
391    /// Path to a plain-text policy file. Each non-empty, non-comment line is one policy.
392    pub policy_file: Option<String>,
393    /// Whether to allow tool calls when the policy LLM fails (timeout/error).
394    /// Default: `false` (fail-closed / deny on error).
395    ///
396    /// Setting this to `true` trades security for availability. Use only in
397    /// deployments where the declarative `PolicyEnforcer` already covers hard rules.
398    #[serde(default)]
399    pub fail_open: bool,
400    /// Timeout in milliseconds for a single policy LLM call. Default: 3000.
401    #[serde(default = "default_adversarial_timeout_ms")]
402    pub timeout_ms: u64,
403    /// Tool names that are always allowed through the adversarial policy gate,
404    /// regardless of policy content. Covers internal agent operations that are
405    /// not externally visible side effects.
406    #[serde(default = "AdversarialPolicyConfig::default_exempt_tools")]
407    pub exempt_tools: Vec<String>,
408}
409impl Default for AdversarialPolicyConfig {
410    fn default() -> Self {
411        Self {
412            enabled: false,
413            policy_provider: String::new(),
414            policy_file: None,
415            fail_open: false,
416            timeout_ms: default_adversarial_timeout_ms(),
417            exempt_tools: Self::default_exempt_tools(),
418        }
419    }
420}
421impl AdversarialPolicyConfig {
422    fn default_exempt_tools() -> Vec<String> {
423        vec![
424            "memory_save".into(),
425            "memory_search".into(),
426            "read_overflow".into(),
427            "load_skill".into(),
428            "invoke_skill".into(),
429            "schedule_deferred".into(),
430        ]
431    }
432}
433
434/// Per-path read allow/deny sandbox for the file tool.
435///
436/// Evaluation order: deny-then-allow. If a path matches `deny_read` and does NOT
437/// match `allow_read`, access is denied. Empty `deny_read` means no read restrictions.
438///
439/// All patterns are matched against the canonicalized (absolute, symlink-resolved) path.
440#[derive(Debug, Clone, Default, Deserialize, Serialize)]
441pub struct FileConfig {
442    /// Glob patterns for paths denied for reading. Evaluated first.
443    #[serde(default)]
444    pub deny_read: Vec<String>,
445    /// Glob patterns for paths allowed for reading. Evaluated second (overrides deny).
446    #[serde(default)]
447    pub allow_read: Vec<String>,
448}
449
450/// Top-level configuration for tool execution.
451#[derive(Debug, Deserialize, Serialize)]
452pub struct ToolsConfig {
453    #[serde(default = "default_true")]
454    pub enabled: bool,
455    #[serde(default = "default_true")]
456    pub summarize_output: bool,
457    #[serde(default)]
458    pub shell: ShellConfig,
459    #[serde(default)]
460    pub scrape: ScrapeConfig,
461    #[serde(default)]
462    pub audit: AuditConfig,
463    #[serde(default)]
464    pub permissions: Option<PermissionsConfig>,
465    #[serde(default)]
466    pub filters: crate::filter::FilterConfig,
467    #[serde(default)]
468    pub overflow: OverflowConfig,
469    #[serde(default)]
470    pub anomaly: AnomalyConfig,
471    #[serde(default)]
472    pub result_cache: ResultCacheConfig,
473    #[serde(default)]
474    pub tafc: TafcConfig,
475    #[serde(default)]
476    pub dependencies: DependencyConfig,
477    #[serde(default)]
478    pub retry: RetryConfig,
479    /// Declarative policy compiler for tool call authorization.
480    #[serde(default)]
481    pub policy: PolicyConfig,
482    /// LLM-based adversarial policy agent for natural-language policy enforcement.
483    #[serde(default)]
484    pub adversarial_policy: AdversarialPolicyConfig,
485    /// Utility-guided tool dispatch gate.
486    #[serde(default)]
487    pub utility: UtilityScoringConfig,
488    /// Per-path read allow/deny sandbox for the file tool.
489    #[serde(default)]
490    pub file: FileConfig,
491    /// OAP declarative pre-action authorization. Rules are merged into `PolicyEnforcer` at
492    /// startup. Authorization rules are appended after `policy.rules` — policy rules take
493    /// precedence (first-match-wins semantics). This means existing policy allow/deny rules
494    /// are evaluated before authorization rules.
495    #[serde(default)]
496    pub authorization: AuthorizationConfig,
497    /// Maximum tool calls allowed per agent session. `None` = unlimited (default).
498    /// Counted on the first attempt only — retries do not consume additional quota slots.
499    #[serde(default)]
500    pub max_tool_calls_per_session: Option<u32>,
501    /// Speculative tool execution configuration.
502    ///
503    /// Runtime-only; no cargo feature gate. Default mode is `off`.
504    #[serde(default)]
505    pub speculative: SpeculativeConfig,
506    /// OS-level subprocess sandbox configuration (`[tools.sandbox]` TOML section).
507    ///
508    /// When `enabled = true`, all shell commands are wrapped in an OS-native sandbox
509    /// (macOS Seatbelt or Linux bwrap + Landlock). Default: disabled.
510    #[serde(default)]
511    pub sandbox: SandboxConfig,
512    /// Egress network event logging configuration.
513    #[serde(default)]
514    pub egress: EgressConfig,
515}
516
517impl ToolsConfig {
518    /// Build a `PermissionPolicy` from explicit config or legacy shell fields.
519    #[must_use]
520    pub fn permission_policy(&self, autonomy_level: AutonomyLevel) -> PermissionPolicy {
521        let policy = if let Some(ref perms) = self.permissions {
522            PermissionPolicy::from(perms.clone())
523        } else {
524            PermissionPolicy::from_legacy(
525                &self.shell.blocked_commands,
526                &self.shell.confirm_patterns,
527            )
528        };
529        policy.with_autonomy(autonomy_level)
530    }
531}
532
533/// Shell-specific configuration: timeout, command blocklist, and allowlist overrides.
534#[derive(Debug, Deserialize, Serialize)]
535#[allow(clippy::struct_excessive_bools)]
536pub struct ShellConfig {
537    #[serde(default = "default_timeout")]
538    pub timeout: u64,
539    #[serde(default)]
540    pub blocked_commands: Vec<String>,
541    #[serde(default)]
542    pub allowed_commands: Vec<String>,
543    #[serde(default)]
544    pub allowed_paths: Vec<String>,
545    #[serde(default = "default_true")]
546    pub allow_network: bool,
547    #[serde(default = "default_confirm_patterns")]
548    pub confirm_patterns: Vec<String>,
549    /// Environment variable name prefixes to strip from subprocess environment.
550    /// Variables whose names start with any of these prefixes are removed before
551    /// spawning shell commands. Default covers common credential naming conventions.
552    #[serde(default = "ShellConfig::default_env_blocklist")]
553    pub env_blocklist: Vec<String>,
554    /// Enable transactional mode: snapshot files before write commands, rollback on failure.
555    #[serde(default)]
556    pub transactional: bool,
557    /// Glob patterns defining which paths are eligible for snapshotting.
558    /// Only files matching these patterns (relative to cwd) are captured.
559    /// Empty = snapshot all files referenced in the command.
560    #[serde(default)]
561    pub transaction_scope: Vec<String>,
562    /// Automatically rollback when exit code >= 2. Default: false.
563    /// Exit code 1 is excluded because many tools (grep, diff, test) use it for
564    /// non-error conditions.
565    #[serde(default)]
566    pub auto_rollback: bool,
567    /// Exit codes that trigger auto-rollback. Default: empty (uses >= 2 heuristic).
568    /// When non-empty, only these exact exit codes trigger rollback.
569    #[serde(default)]
570    pub auto_rollback_exit_codes: Vec<i32>,
571    /// When true, snapshot failure aborts execution with an error.
572    /// When false (default), snapshot failure emits a warning and execution proceeds.
573    #[serde(default)]
574    pub snapshot_required: bool,
575    /// Maximum cumulative bytes for transaction snapshots. 0 = unlimited.
576    #[serde(default)]
577    pub max_snapshot_bytes: u64,
578    /// Maximum number of concurrent background shell runs. Default: 8.
579    ///
580    /// When this limit is reached, new `background = true` tool calls are rejected with
581    /// a `Blocked` error until existing runs complete.
582    #[serde(default = "default_max_background_runs")]
583    pub max_background_runs: usize,
584    /// Timeout in seconds for each background shell run. Default: 1800 (30 minutes).
585    ///
586    /// Runs that exceed this timeout are killed and a completion event with
587    /// `success = false` is emitted.
588    #[serde(default = "default_background_timeout_secs")]
589    pub background_timeout_secs: u64,
590}
591
592impl Default for ShellConfig {
593    fn default() -> Self {
594        Self {
595            timeout: default_timeout(),
596            blocked_commands: Vec::new(),
597            allowed_commands: Vec::new(),
598            allowed_paths: Vec::new(),
599            allow_network: true,
600            confirm_patterns: default_confirm_patterns(),
601            env_blocklist: Self::default_env_blocklist(),
602            transactional: false,
603            transaction_scope: Vec::new(),
604            auto_rollback: false,
605            auto_rollback_exit_codes: Vec::new(),
606            snapshot_required: false,
607            max_snapshot_bytes: 0,
608            max_background_runs: default_max_background_runs(),
609            background_timeout_secs: default_background_timeout_secs(),
610        }
611    }
612}
613
614impl ShellConfig {
615    #[must_use]
616    pub fn default_env_blocklist() -> Vec<String> {
617        vec![
618            "ZEPH_".into(),
619            "AWS_".into(),
620            "AZURE_".into(),
621            "GCP_".into(),
622            "GOOGLE_".into(),
623            "OPENAI_".into(),
624            "ANTHROPIC_".into(),
625            "HF_".into(),
626            "HUGGING".into(),
627        ]
628    }
629}
630
631/// Configuration for audit logging of tool executions.
632#[derive(Debug, Deserialize, Serialize)]
633pub struct AuditConfig {
634    #[serde(default = "default_true")]
635    pub enabled: bool,
636    #[serde(default = "default_audit_destination")]
637    pub destination: String,
638    /// When true, log a per-tool risk summary at startup.
639    /// Each entry includes: tool name, privilege level, and expected input sanitization.
640    /// This is a design-time risk inventory, NOT runtime static analysis or a guarantee
641    /// that sanitization is functioning correctly.
642    #[serde(default)]
643    pub tool_risk_summary: bool,
644}
645
646impl Default for ToolsConfig {
647    fn default() -> Self {
648        Self {
649            enabled: true,
650            summarize_output: true,
651            shell: ShellConfig::default(),
652            scrape: ScrapeConfig::default(),
653            audit: AuditConfig::default(),
654            permissions: None,
655            filters: crate::filter::FilterConfig::default(),
656            overflow: OverflowConfig::default(),
657            anomaly: AnomalyConfig::default(),
658            result_cache: ResultCacheConfig::default(),
659            tafc: TafcConfig::default(),
660            dependencies: DependencyConfig::default(),
661            retry: RetryConfig::default(),
662            policy: PolicyConfig::default(),
663            adversarial_policy: AdversarialPolicyConfig::default(),
664            utility: UtilityScoringConfig::default(),
665            file: FileConfig::default(),
666            authorization: AuthorizationConfig::default(),
667            max_tool_calls_per_session: None,
668            speculative: SpeculativeConfig::default(),
669            sandbox: SandboxConfig::default(),
670            egress: EgressConfig::default(),
671        }
672    }
673}
674
675fn default_max_in_flight() -> usize {
676    4
677}
678
679fn default_confidence_threshold() -> f32 {
680    0.55
681}
682
683fn default_max_wasted_per_minute() -> u64 {
684    100
685}
686
687fn default_ttl_seconds() -> u64 {
688    30
689}
690
691fn default_min_observations() -> u32 {
692    5
693}
694
695fn default_half_life_days() -> f64 {
696    14.0
697}
698
699/// Speculative tool execution mode.
700///
701/// Controls whether and how the agent pre-dispatches tool calls before the LLM
702/// finishes decoding the full tool-use block.
703#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
704#[serde(rename_all = "kebab-case")]
705pub enum SpeculationMode {
706    /// No speculation; uses existing synchronous path.
707    #[default]
708    Off,
709    /// LLM-decoding level: fires tools when streaming partial JSON has all required fields.
710    Decoding,
711    /// Application-level pattern (PASTE): predicts top-K calls from `SQLite` history.
712    Pattern,
713    /// Both decoding and pattern speculation active.
714    Both,
715}
716
717/// Pattern-based (PASTE) speculative execution config.
718///
719/// Controls the SQLite-backed tool sequence learning subsystem. Disabled by default for
720/// privacy and performance reasons; opt-in per deployment.
721#[derive(Debug, Clone, Deserialize, Serialize)]
722pub struct SpeculativePatternConfig {
723    /// Enable PASTE pattern learning and prediction. Default: false.
724    #[serde(default)]
725    pub enabled: bool,
726    /// Minimum observed occurrences before a prediction is issued.
727    #[serde(default = "default_min_observations")]
728    pub min_observations: u32,
729    /// Exponential decay half-life in days for pattern scoring.
730    #[serde(default = "default_half_life_days")]
731    pub half_life_days: f64,
732    /// LLM provider name (from `[[llm.providers]]`) for optional reranking.
733    /// Empty string disables LLM reranking; scoring-only path is used.
734    #[serde(default)]
735    pub rerank_provider: String,
736}
737
738impl Default for SpeculativePatternConfig {
739    fn default() -> Self {
740        Self {
741            enabled: false,
742            min_observations: default_min_observations(),
743            half_life_days: default_half_life_days(),
744            rerank_provider: String::new(),
745        }
746    }
747}
748
749/// Shell command regex allowlist for speculative execution.
750///
751/// Only commands matching at least one regex in this list are eligible for speculation.
752/// Default: empty (speculation disabled for shell by default).
753#[derive(Debug, Clone, Default, Deserialize, Serialize)]
754pub struct SpeculativeAllowlistConfig {
755    /// Regexes matched against the full `bash` command string. Empty = no shell speculation.
756    #[serde(default)]
757    pub shell: Vec<String>,
758}
759
760/// Top-level configuration for speculative tool execution.
761///
762/// All settings here are runtime-only: no cargo feature gates this section.
763/// The module always compiles; branches are never taken when `mode = "off"`.
764///
765/// # Examples
766///
767/// ```toml
768/// [tools.speculative]
769/// mode = "both"
770/// max_in_flight = 4
771/// ttl_seconds = 30
772///
773/// [tools.speculative.pattern]
774/// enabled = false
775/// ```
776#[derive(Debug, Clone, Deserialize, Serialize)]
777pub struct SpeculativeConfig {
778    /// Speculation mode. Default: `off`.
779    #[serde(default)]
780    pub mode: SpeculationMode,
781    /// Maximum concurrent in-flight speculative tasks. Bounded to `[1, 16]`.
782    #[serde(default = "default_max_in_flight")]
783    pub max_in_flight: usize,
784    /// Minimum confidence score `[0, 1]` to dispatch a speculative task.
785    #[serde(default = "default_confidence_threshold")]
786    pub confidence_threshold: f32,
787    /// Circuit-breaker: disable speculation for 60 s when wasted ms exceeds this per minute.
788    #[serde(default = "default_max_wasted_per_minute")]
789    pub max_wasted_per_minute: u64,
790    /// Per-handle wall-clock TTL in seconds before the handle is cancelled.
791    #[serde(default = "default_ttl_seconds")]
792    pub ttl_seconds: u64,
793    /// Emit `AuditEntry` for speculative dispatches (with `result: speculative_discarded`).
794    #[serde(default = "default_true")]
795    pub audit: bool,
796    /// PASTE pattern learning config.
797    #[serde(default)]
798    pub pattern: SpeculativePatternConfig,
799    /// Per-executor command allowlists.
800    #[serde(default)]
801    pub allowlist: SpeculativeAllowlistConfig,
802}
803
804impl Default for SpeculativeConfig {
805    fn default() -> Self {
806        Self {
807            mode: SpeculationMode::Off,
808            max_in_flight: default_max_in_flight(),
809            confidence_threshold: default_confidence_threshold(),
810            max_wasted_per_minute: default_max_wasted_per_minute(),
811            ttl_seconds: default_ttl_seconds(),
812            audit: true,
813            pattern: SpeculativePatternConfig::default(),
814            allowlist: SpeculativeAllowlistConfig::default(),
815        }
816    }
817}
818
819impl Default for AuditConfig {
820    fn default() -> Self {
821        Self {
822            enabled: true,
823            destination: default_audit_destination(),
824            tool_risk_summary: false,
825        }
826    }
827}
828
829/// OAP-style declarative authorization. Rules are merged into `PolicyEnforcer` at startup.
830///
831/// Precedence: `policy.rules` are evaluated first (first-match-wins), then `authorization.rules`.
832/// Use `[tools.policy]` for deny-wins safety rules; use `[tools.authorization]` for
833/// capability-based allow/deny rules that layer on top.
834#[derive(Debug, Clone, Default, Deserialize, Serialize)]
835pub struct AuthorizationConfig {
836    /// Enable OAP authorization checks. When false, `rules` are ignored. Default: false.
837    #[serde(default)]
838    pub enabled: bool,
839    /// Per-tool authorization rules. Appended after `[tools.policy]` rules at startup.
840    #[serde(default)]
841    pub rules: Vec<PolicyRuleConfig>,
842}
843
844/// Configuration for egress network event logging.
845///
846/// Controls what outbound HTTP events are emitted to the audit JSONL stream and
847/// surfaced in the TUI Security panel. Domain allow/deny policy is NOT duplicated
848/// here — it remains solely in [`ScrapeConfig`].
849#[derive(Debug, Clone, Deserialize, Serialize)]
850#[serde(default)]
851#[allow(clippy::struct_excessive_bools)]
852pub struct EgressConfig {
853    /// Master switch for egress event emission. Default: `true`.
854    pub enabled: bool,
855    /// Emit [`EgressEvent`](crate::audit::EgressEvent)s for requests blocked by
856    /// SSRF/domain/scheme checks. Default: `true`.
857    pub log_blocked: bool,
858    /// Include `response_bytes` in the JSONL record. Default: `true`.
859    pub log_response_bytes: bool,
860    /// Show real hostname in `MetricsSnapshot::egress_recent` (TUI). When `false`,
861    /// `"***"` is stored instead. JSONL always keeps the real host. Default: `true`.
862    pub log_hosts_to_tui: bool,
863}
864
865impl Default for EgressConfig {
866    fn default() -> Self {
867        Self {
868            enabled: true,
869            log_blocked: true,
870            log_response_bytes: true,
871            log_hosts_to_tui: true,
872        }
873    }
874}
875
876fn default_scrape_timeout() -> u64 {
877    15
878}
879
880fn default_max_body_bytes() -> usize {
881    4_194_304
882}
883
884/// Configuration for the web scrape tool.
885#[derive(Debug, Deserialize, Serialize)]
886pub struct ScrapeConfig {
887    #[serde(default = "default_scrape_timeout")]
888    pub timeout: u64,
889    #[serde(default = "default_max_body_bytes")]
890    pub max_body_bytes: usize,
891    /// Domain allowlist. Empty = all public domains allowed (default, existing behavior).
892    /// When non-empty, ONLY URLs whose host matches an entry are permitted (deny-unknown).
893    /// Supports exact match (`"docs.rs"`) and wildcard prefix (`"*.rust-lang.org"`).
894    /// Wildcard `*` matches a single subdomain segment only.
895    ///
896    /// Operators SHOULD set an explicit allowlist in production deployments.
897    /// Empty allowlist with a non-empty `denied_domains` is a denylist-only configuration
898    /// which is NOT a security boundary — an attacker can use any domain not on the list.
899    #[serde(default)]
900    pub allowed_domains: Vec<String>,
901    /// Domain denylist. Always enforced, regardless of allowlist state.
902    /// Supports the same pattern syntax as `allowed_domains`.
903    #[serde(default)]
904    pub denied_domains: Vec<String>,
905}
906
907impl Default for ScrapeConfig {
908    fn default() -> Self {
909        Self {
910            timeout: default_scrape_timeout(),
911            max_body_bytes: default_max_body_bytes(),
912            allowed_domains: Vec::new(),
913            denied_domains: Vec::new(),
914        }
915    }
916}
917
918fn default_sandbox_profile() -> crate::sandbox::SandboxProfile {
919    crate::sandbox::SandboxProfile::Workspace
920}
921
922fn default_sandbox_backend() -> String {
923    "auto".into()
924}
925
926/// OS-level subprocess sandbox configuration (`[tools.sandbox]` TOML section).
927///
928/// When `enabled = true`, all shell commands are wrapped in an OS-native sandbox:
929/// - **macOS**: `sandbox-exec` (Seatbelt) with a generated `TinyScheme` profile.
930/// - **Linux** (requires `sandbox` cargo feature): `bwrap` + Landlock + seccomp BPF.
931///
932/// This sandbox applies **only to subprocess executors** (shell). In-process executors
933/// (`WebScrapeExecutor`, `FileExecutor`) are not covered — see `NFR-SB-1`.
934///
935/// # Examples
936///
937/// ```toml
938/// [tools.sandbox]
939/// enabled = true
940/// profile = "workspace"
941/// allow_read  = ["$HOME/.cache/zeph"]
942/// allow_write = ["./.local"]
943/// strict = true
944/// backend = "auto"
945/// ```
946#[derive(Debug, Clone, Deserialize, Serialize)]
947pub struct SandboxConfig {
948    /// Enable OS-level sandbox. Default: `false`.
949    ///
950    /// On Linux requires the `sandbox` cargo feature. When `true` but the feature is absent,
951    /// startup emits `WARN` and degrades to noop (fail-open). Use `strict = true` to
952    /// make the feature absence an error instead.
953    #[serde(default)]
954    pub enabled: bool,
955
956    /// Enforcement profile controlling the baseline restrictions.
957    #[serde(default = "default_sandbox_profile")]
958    pub profile: crate::sandbox::SandboxProfile,
959
960    /// Additional paths granted read access. Resolved to absolute paths at startup.
961    #[serde(default)]
962    pub allow_read: Vec<std::path::PathBuf>,
963
964    /// Additional paths granted write access. Resolved to absolute paths at startup.
965    #[serde(default)]
966    pub allow_write: Vec<std::path::PathBuf>,
967
968    /// When `true`, sandbox initialization failure aborts startup (fail-closed). Default: `true`.
969    #[serde(default = "default_true")]
970    pub strict: bool,
971
972    /// OS backend hint: `"auto"` / `"seatbelt"` / `"landlock-bwrap"` / `"noop"`.
973    ///
974    /// `"auto"` selects the best available backend for the current platform.
975    #[serde(default = "default_sandbox_backend")]
976    pub backend: String,
977
978    /// Hostnames (or single-level wildcard patterns) denied network egress from sandboxed
979    /// subprocesses. Enforcement is platform-specific:
980    ///
981    /// - **macOS Seatbelt**: injects `(deny network* (remote host "<host>"))` rules after
982    ///   `(allow network*)` so Seatbelt's last-rule-wins semantics block the listed hosts.
983    /// - **Linux bwrap**: mounts a synthetic `/etc/hosts` that resolves denied names to
984    ///   `0.0.0.0`. This is best-effort — processes using custom DNS clients, IP literals,
985    ///   or HTTP proxies can bypass this filter.
986    ///
987    /// On `NoopSandbox` (unsupported platform), denied domains cannot be enforced.
988    /// See `fail_if_unavailable` to make that a startup error instead of a warning.
989    ///
990    /// Patterns follow the same syntax as `[tools.scrape].denied_domains`:
991    /// exact hostname or `*.suffix` (single subdomain level).
992    #[serde(default)]
993    pub denied_domains: Vec<String>,
994
995    /// When `true`, failure to activate an effective OS sandbox (noop selected, backend
996    /// missing, or platform unsupported) aborts startup with an error.
997    ///
998    /// This is stricter than `strict`: `strict` only gates *missing backend binary* errors,
999    /// while `fail_if_unavailable` additionally rejects `NoopSandbox` selection (e.g. on an
1000    /// unsupported platform). Default: `false`.
1001    #[serde(default)]
1002    pub fail_if_unavailable: bool,
1003}
1004
1005impl Default for SandboxConfig {
1006    fn default() -> Self {
1007        Self {
1008            enabled: false,
1009            profile: default_sandbox_profile(),
1010            allow_read: Vec::new(),
1011            allow_write: Vec::new(),
1012            strict: true,
1013            backend: default_sandbox_backend(),
1014            denied_domains: Vec::new(),
1015            fail_if_unavailable: false,
1016        }
1017    }
1018}
1019
1020impl SandboxConfig {
1021    /// Validate `denied_domains` entries.
1022    ///
1023    /// Each entry must contain only alphanumeric characters, dots, hyphens, and an
1024    /// optional leading `*` wildcard. Returns `Err` with a descriptive message on the
1025    /// first invalid entry.
1026    ///
1027    /// # Errors
1028    ///
1029    /// Returns an error string when any pattern contains invalid characters.
1030    pub fn validate_denied_domains(&self) -> Result<(), String> {
1031        crate::domain_match::validate_domain_patterns(&self.denied_domains)
1032    }
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037    use super::*;
1038
1039    #[test]
1040    fn deserialize_default_config() {
1041        let toml_str = r#"
1042            enabled = true
1043
1044            [shell]
1045            timeout = 60
1046            blocked_commands = ["rm -rf /", "sudo"]
1047        "#;
1048
1049        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1050        assert!(config.enabled);
1051        assert_eq!(config.shell.timeout, 60);
1052        assert_eq!(config.shell.blocked_commands.len(), 2);
1053        assert_eq!(config.shell.blocked_commands[0], "rm -rf /");
1054        assert_eq!(config.shell.blocked_commands[1], "sudo");
1055    }
1056
1057    #[test]
1058    fn empty_blocked_commands() {
1059        let toml_str = r"
1060            [shell]
1061            timeout = 30
1062        ";
1063
1064        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1065        assert!(config.enabled);
1066        assert_eq!(config.shell.timeout, 30);
1067        assert!(config.shell.blocked_commands.is_empty());
1068    }
1069
1070    #[test]
1071    fn default_tools_config() {
1072        let config = ToolsConfig::default();
1073        assert!(config.enabled);
1074        assert!(config.summarize_output);
1075        assert_eq!(config.shell.timeout, 30);
1076        assert!(config.shell.blocked_commands.is_empty());
1077        assert!(config.audit.enabled);
1078    }
1079
1080    #[test]
1081    fn tools_summarize_output_default_true() {
1082        let config = ToolsConfig::default();
1083        assert!(config.summarize_output);
1084    }
1085
1086    #[test]
1087    fn tools_summarize_output_parsing() {
1088        let toml_str = r"
1089            summarize_output = true
1090        ";
1091        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1092        assert!(config.summarize_output);
1093    }
1094
1095    #[test]
1096    fn default_shell_config() {
1097        let config = ShellConfig::default();
1098        assert_eq!(config.timeout, 30);
1099        assert!(config.blocked_commands.is_empty());
1100        assert!(config.allowed_paths.is_empty());
1101        assert!(config.allow_network);
1102        assert!(!config.confirm_patterns.is_empty());
1103    }
1104
1105    #[test]
1106    fn deserialize_omitted_fields_use_defaults() {
1107        let toml_str = "";
1108        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1109        assert!(config.enabled);
1110        assert_eq!(config.shell.timeout, 30);
1111        assert!(config.shell.blocked_commands.is_empty());
1112        assert!(config.shell.allow_network);
1113        assert!(!config.shell.confirm_patterns.is_empty());
1114        assert_eq!(config.scrape.timeout, 15);
1115        assert_eq!(config.scrape.max_body_bytes, 4_194_304);
1116        assert!(config.audit.enabled);
1117        assert_eq!(config.audit.destination, "stdout");
1118        assert!(config.summarize_output);
1119    }
1120
1121    #[test]
1122    fn default_scrape_config() {
1123        let config = ScrapeConfig::default();
1124        assert_eq!(config.timeout, 15);
1125        assert_eq!(config.max_body_bytes, 4_194_304);
1126    }
1127
1128    #[test]
1129    fn deserialize_scrape_config() {
1130        let toml_str = r"
1131            [scrape]
1132            timeout = 30
1133            max_body_bytes = 2097152
1134        ";
1135
1136        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1137        assert_eq!(config.scrape.timeout, 30);
1138        assert_eq!(config.scrape.max_body_bytes, 2_097_152);
1139    }
1140
1141    #[test]
1142    fn tools_config_default_includes_scrape() {
1143        let config = ToolsConfig::default();
1144        assert_eq!(config.scrape.timeout, 15);
1145        assert_eq!(config.scrape.max_body_bytes, 4_194_304);
1146    }
1147
1148    #[test]
1149    fn deserialize_allowed_commands() {
1150        let toml_str = r#"
1151            [shell]
1152            timeout = 30
1153            allowed_commands = ["curl", "wget"]
1154        "#;
1155
1156        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1157        assert_eq!(config.shell.allowed_commands, vec!["curl", "wget"]);
1158    }
1159
1160    #[test]
1161    fn default_allowed_commands_empty() {
1162        let config = ShellConfig::default();
1163        assert!(config.allowed_commands.is_empty());
1164    }
1165
1166    #[test]
1167    fn deserialize_shell_security_fields() {
1168        let toml_str = r#"
1169            [shell]
1170            allowed_paths = ["/tmp", "/home/user"]
1171            allow_network = false
1172            confirm_patterns = ["rm ", "drop table"]
1173        "#;
1174
1175        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1176        assert_eq!(config.shell.allowed_paths, vec!["/tmp", "/home/user"]);
1177        assert!(!config.shell.allow_network);
1178        assert_eq!(config.shell.confirm_patterns, vec!["rm ", "drop table"]);
1179    }
1180
1181    #[test]
1182    fn deserialize_audit_config() {
1183        let toml_str = r#"
1184            [audit]
1185            enabled = true
1186            destination = "/var/log/zeph-audit.log"
1187        "#;
1188
1189        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1190        assert!(config.audit.enabled);
1191        assert_eq!(config.audit.destination, "/var/log/zeph-audit.log");
1192    }
1193
1194    #[test]
1195    fn default_audit_config() {
1196        let config = AuditConfig::default();
1197        assert!(config.enabled);
1198        assert_eq!(config.destination, "stdout");
1199    }
1200
1201    #[test]
1202    fn permission_policy_from_legacy_fields() {
1203        let config = ToolsConfig {
1204            shell: ShellConfig {
1205                blocked_commands: vec!["sudo".to_owned()],
1206                confirm_patterns: vec!["rm ".to_owned()],
1207                ..ShellConfig::default()
1208            },
1209            ..ToolsConfig::default()
1210        };
1211        let policy = config.permission_policy(AutonomyLevel::Supervised);
1212        assert_eq!(
1213            policy.check("bash", "sudo apt"),
1214            crate::permissions::PermissionAction::Deny
1215        );
1216        assert_eq!(
1217            policy.check("bash", "rm file"),
1218            crate::permissions::PermissionAction::Ask
1219        );
1220    }
1221
1222    #[test]
1223    fn permission_policy_from_explicit_config() {
1224        let toml_str = r#"
1225            [permissions]
1226            [[permissions.bash]]
1227            pattern = "*sudo*"
1228            action = "deny"
1229        "#;
1230        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1231        let policy = config.permission_policy(AutonomyLevel::Supervised);
1232        assert_eq!(
1233            policy.check("bash", "sudo rm"),
1234            crate::permissions::PermissionAction::Deny
1235        );
1236    }
1237
1238    #[test]
1239    fn permission_policy_default_uses_legacy() {
1240        let config = ToolsConfig::default();
1241        assert!(config.permissions.is_none());
1242        let policy = config.permission_policy(AutonomyLevel::Supervised);
1243        // Default ShellConfig has confirm_patterns, so legacy rules are generated
1244        assert!(!config.shell.confirm_patterns.is_empty());
1245        assert!(policy.rules().contains_key("bash"));
1246    }
1247
1248    #[test]
1249    fn deserialize_overflow_config_full() {
1250        let toml_str = r"
1251            [overflow]
1252            threshold = 100000
1253            retention_days = 14
1254            max_overflow_bytes = 5242880
1255        ";
1256        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1257        assert_eq!(config.overflow.threshold, 100_000);
1258        assert_eq!(config.overflow.retention_days, 14);
1259        assert_eq!(config.overflow.max_overflow_bytes, 5_242_880);
1260    }
1261
1262    #[test]
1263    fn deserialize_overflow_config_unknown_dir_field_is_ignored() {
1264        // Old configs with `dir = "..."` must not fail deserialization.
1265        let toml_str = r#"
1266            [overflow]
1267            threshold = 75000
1268            dir = "/tmp/overflow"
1269        "#;
1270        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1271        assert_eq!(config.overflow.threshold, 75_000);
1272    }
1273
1274    #[test]
1275    fn deserialize_overflow_config_partial_uses_defaults() {
1276        let toml_str = r"
1277            [overflow]
1278            threshold = 75000
1279        ";
1280        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1281        assert_eq!(config.overflow.threshold, 75_000);
1282        assert_eq!(config.overflow.retention_days, 7);
1283    }
1284
1285    #[test]
1286    fn deserialize_overflow_config_omitted_uses_defaults() {
1287        let config: ToolsConfig = toml::from_str("").unwrap();
1288        assert_eq!(config.overflow.threshold, 50_000);
1289        assert_eq!(config.overflow.retention_days, 7);
1290        assert_eq!(config.overflow.max_overflow_bytes, 10 * 1024 * 1024);
1291    }
1292
1293    #[test]
1294    fn result_cache_config_defaults() {
1295        let config = ResultCacheConfig::default();
1296        assert!(config.enabled);
1297        assert_eq!(config.ttl_secs, 300);
1298    }
1299
1300    #[test]
1301    fn deserialize_result_cache_config() {
1302        let toml_str = r"
1303            [result_cache]
1304            enabled = false
1305            ttl_secs = 60
1306        ";
1307        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1308        assert!(!config.result_cache.enabled);
1309        assert_eq!(config.result_cache.ttl_secs, 60);
1310    }
1311
1312    #[test]
1313    fn result_cache_omitted_uses_defaults() {
1314        let config: ToolsConfig = toml::from_str("").unwrap();
1315        assert!(config.result_cache.enabled);
1316        assert_eq!(config.result_cache.ttl_secs, 300);
1317    }
1318
1319    #[test]
1320    fn result_cache_ttl_zero_is_valid() {
1321        let toml_str = r"
1322            [result_cache]
1323            ttl_secs = 0
1324        ";
1325        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1326        assert_eq!(config.result_cache.ttl_secs, 0);
1327    }
1328
1329    #[test]
1330    fn adversarial_policy_default_exempt_tools_contains_skill_ops() {
1331        let exempt = AdversarialPolicyConfig::default_exempt_tools();
1332        assert!(
1333            exempt.contains(&"load_skill".to_string()),
1334            "default exempt_tools must contain load_skill"
1335        );
1336        assert!(
1337            exempt.contains(&"invoke_skill".to_string()),
1338            "default exempt_tools must contain invoke_skill"
1339        );
1340    }
1341
1342    #[test]
1343    fn utility_scoring_default_exempt_tools_contains_skill_ops() {
1344        let cfg = UtilityScoringConfig::default();
1345        assert!(
1346            cfg.exempt_tools.contains(&"invoke_skill".to_string()),
1347            "UtilityScoringConfig default exempt_tools must contain invoke_skill"
1348        );
1349        assert!(
1350            cfg.exempt_tools.contains(&"load_skill".to_string()),
1351            "UtilityScoringConfig default exempt_tools must contain load_skill"
1352        );
1353    }
1354
1355    #[test]
1356    fn utility_partial_toml_exempt_tools_uses_default_not_empty_vec() {
1357        // Regression: #[serde(default)] on exempt_tools called Vec::default() (empty)
1358        // instead of the struct-level Default which sets ["invoke_skill", "load_skill"].
1359        let toml_str = r"
1360            [utility]
1361            enabled = true
1362            threshold = 0.1
1363        ";
1364        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1365        assert!(
1366            config
1367                .utility
1368                .exempt_tools
1369                .contains(&"invoke_skill".to_string()),
1370            "partial [tools.utility] TOML must populate exempt_tools with invoke_skill"
1371        );
1372        assert!(
1373            config
1374                .utility
1375                .exempt_tools
1376                .contains(&"load_skill".to_string()),
1377            "partial [tools.utility] TOML must populate exempt_tools with load_skill"
1378        );
1379    }
1380}