Skip to main content

zeph_config/
tools.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Pure-data tool configuration types.
5//!
6//! Contains all TOML-deserializable configuration structs for tool execution. Runtime
7//! types (executors, permission policy enforcement) remain in `zeph-tools`. That crate
8//! re-exports the types here so existing import paths continue to resolve.
9
10use std::collections::HashMap;
11use std::path::PathBuf;
12
13use serde::{Deserialize, Serialize};
14
15use crate::providers::ProviderName;
16use zeph_common::SkillTrustLevel;
17
18// ── Permissions ──────────────────────────────────────────────────────────────
19
20/// Tool access level controlling agent autonomy.
21#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
22#[serde(rename_all = "lowercase")]
23#[non_exhaustive]
24pub enum AutonomyLevel {
25    /// Read-only tools: `read`, `find_path`, `grep`, `list_directory`, `web_scrape`, `fetch`
26    ReadOnly,
27    /// Default: rule-based permissions with confirmations.
28    #[default]
29    Supervised,
30    /// All tools allowed, no confirmations.
31    Full,
32}
33
34/// Action a permission rule resolves to.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
36#[serde(rename_all = "lowercase")]
37#[non_exhaustive]
38pub enum PermissionAction {
39    /// Allow the tool call unconditionally.
40    Allow,
41    /// Prompt the user before allowing.
42    Ask,
43    /// Deny the tool call.
44    Deny,
45}
46
47/// Single permission rule: glob `pattern` + action.
48#[derive(Debug, Clone, Deserialize, Serialize)]
49pub struct PermissionRule {
50    /// Glob pattern matched against the tool input string.
51    pub pattern: String,
52    /// Action to take when the pattern matches.
53    pub action: PermissionAction,
54}
55
56/// TOML-deserializable permissions config section.
57#[derive(Debug, Clone, Deserialize, Serialize, Default)]
58pub struct PermissionsConfig {
59    /// Per-tool permission rules. Key is `tool_id`.
60    #[serde(flatten)]
61    pub tools: HashMap<String, Vec<PermissionRule>>,
62}
63
64// ── Verifiers ────────────────────────────────────────────────────────────────
65
66fn default_true() -> bool {
67    true
68}
69
70fn default_shell_tools() -> Vec<String> {
71    vec![
72        "bash".to_string(),
73        "shell".to_string(),
74        "terminal".to_string(),
75    ]
76}
77
78fn default_guarded_tools() -> Vec<String> {
79    vec!["fetch".to_string(), "web_scrape".to_string()]
80}
81
82/// Configuration for the destructive command verifier.
83#[derive(Debug, Clone, Deserialize, Serialize)]
84pub struct DestructiveVerifierConfig {
85    /// Enable the verifier. Default: `true`.
86    #[serde(default = "default_true")]
87    pub enabled: bool,
88    /// Explicit path prefixes under which destructive commands are permitted.
89    #[serde(default)]
90    pub allowed_paths: Vec<String>,
91    /// Additional command patterns to treat as destructive (substring match).
92    #[serde(default)]
93    pub extra_patterns: Vec<String>,
94    /// Tool names to treat as shell executors (case-insensitive).
95    #[serde(default = "default_shell_tools")]
96    pub shell_tools: Vec<String>,
97}
98
99impl Default for DestructiveVerifierConfig {
100    fn default() -> Self {
101        Self {
102            enabled: true,
103            allowed_paths: Vec::new(),
104            extra_patterns: Vec::new(),
105            shell_tools: default_shell_tools(),
106        }
107    }
108}
109
110/// Configuration for the injection pattern verifier.
111#[derive(Debug, Clone, Deserialize, Serialize)]
112pub struct InjectionVerifierConfig {
113    /// Enable the verifier. Default: `true`.
114    #[serde(default = "default_true")]
115    pub enabled: bool,
116    /// Additional injection patterns to block (regex strings).
117    #[serde(default)]
118    pub extra_patterns: Vec<String>,
119    /// URLs explicitly permitted even if they match SSRF patterns.
120    #[serde(default)]
121    pub allowlisted_urls: Vec<String>,
122}
123
124impl Default for InjectionVerifierConfig {
125    fn default() -> Self {
126        Self {
127            enabled: true,
128            extra_patterns: Vec::new(),
129            allowlisted_urls: Vec::new(),
130        }
131    }
132}
133
134/// Configuration for the URL grounding verifier.
135#[derive(Debug, Clone, Deserialize, Serialize)]
136pub struct UrlGroundingVerifierConfig {
137    /// Enable the verifier. Default: `true`.
138    #[serde(default = "default_true")]
139    pub enabled: bool,
140    /// Tool IDs subject to URL grounding checks.
141    #[serde(default = "default_guarded_tools")]
142    pub guarded_tools: Vec<String>,
143}
144
145impl Default for UrlGroundingVerifierConfig {
146    fn default() -> Self {
147        Self {
148            enabled: true,
149            guarded_tools: default_guarded_tools(),
150        }
151    }
152}
153
154/// Configuration for the firewall verifier.
155#[derive(Debug, Clone, Deserialize, Serialize)]
156pub struct FirewallVerifierConfig {
157    /// Enable the verifier. Default: `true`.
158    #[serde(default = "default_true")]
159    pub enabled: bool,
160    /// Glob patterns for additional paths to block.
161    #[serde(default)]
162    pub blocked_paths: Vec<String>,
163    /// Additional environment variable names to block from tool arguments.
164    #[serde(default)]
165    pub blocked_env_vars: Vec<String>,
166    /// Tool IDs exempt from firewall scanning.
167    #[serde(default)]
168    pub exempt_tools: Vec<String>,
169}
170
171impl Default for FirewallVerifierConfig {
172    fn default() -> Self {
173        Self {
174            enabled: true,
175            blocked_paths: Vec::new(),
176            blocked_env_vars: Vec::new(),
177            exempt_tools: Vec::new(),
178        }
179    }
180}
181
182/// Top-level configuration for all pre-execution verifiers.
183#[derive(Debug, Clone, Deserialize, Serialize)]
184pub struct PreExecutionVerifierConfig {
185    /// Enable all verifiers globally. Default: `true`.
186    #[serde(default = "default_true")]
187    pub enabled: bool,
188    /// Destructive command verifier settings.
189    #[serde(default)]
190    pub destructive_commands: DestructiveVerifierConfig,
191    /// Injection pattern verifier settings.
192    #[serde(default)]
193    pub injection_patterns: InjectionVerifierConfig,
194    /// URL grounding verifier settings.
195    #[serde(default)]
196    pub url_grounding: UrlGroundingVerifierConfig,
197    /// Firewall verifier settings.
198    #[serde(default)]
199    pub firewall: FirewallVerifierConfig,
200}
201
202impl Default for PreExecutionVerifierConfig {
203    fn default() -> Self {
204        Self {
205            enabled: true,
206            destructive_commands: DestructiveVerifierConfig::default(),
207            injection_patterns: InjectionVerifierConfig::default(),
208            url_grounding: UrlGroundingVerifierConfig::default(),
209            firewall: FirewallVerifierConfig::default(),
210        }
211    }
212}
213
214// ── Policy ───────────────────────────────────────────────────────────────────
215
216/// Effect applied when a policy rule matches.
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
218#[serde(rename_all = "snake_case")]
219#[non_exhaustive]
220pub enum PolicyEffect {
221    /// Allow the tool call.
222    Allow,
223    /// Deny the tool call.
224    Deny,
225}
226
227/// Default effect when no policy rule matches.
228#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
229#[serde(rename_all = "lowercase")]
230#[non_exhaustive]
231pub enum DefaultEffect {
232    /// Allow the call when no rule matches.
233    Allow,
234    /// Deny the call when no rule matches (default, fail-closed).
235    #[default]
236    Deny,
237}
238
239fn default_deny() -> DefaultEffect {
240    DefaultEffect::Deny
241}
242
243/// TOML-deserializable policy configuration.
244#[derive(Debug, Clone, Deserialize, Serialize, Default)]
245pub struct PolicyConfig {
246    /// Whether to enforce policy rules. When false, all calls are allowed.
247    #[serde(default)]
248    pub enabled: bool,
249    /// Fallback effect when no rule matches.
250    #[serde(default = "default_deny")]
251    pub default_effect: DefaultEffect,
252    /// Inline policy rules.
253    #[serde(default)]
254    pub rules: Vec<PolicyRuleConfig>,
255    /// Optional external policy file (TOML). When set, overrides inline rules.
256    pub policy_file: Option<String>,
257    /// Provider name for LLM-assisted policy checks. Empty = disabled.
258    #[serde(default)]
259    pub policy_provider: ProviderName,
260}
261
262/// A single policy rule as read from TOML.
263#[derive(Debug, Clone, Deserialize, Serialize)]
264pub struct PolicyRuleConfig {
265    /// Effect when the rule matches.
266    pub effect: PolicyEffect,
267    /// Glob pattern matching the tool id. Required.
268    pub tool: String,
269    /// Path globs matched against path-like params. Rule fires if ANY path matches.
270    #[serde(default)]
271    pub paths: Vec<String>,
272    /// Env var names that must all be present in the policy context.
273    #[serde(default)]
274    pub env: Vec<String>,
275    /// Minimum required trust level (rule fires only when context trust <= threshold).
276    pub trust_level: Option<SkillTrustLevel>,
277    /// Regex matched against individual string param values.
278    pub args_match: Option<String>,
279    /// Named capabilities associated with this rule.
280    #[serde(default)]
281    pub capabilities: Vec<String>,
282}
283
284// ── Sandbox ──────────────────────────────────────────────────────────────────
285
286/// Baseline restriction profile for the OS-level sandbox.
287#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
288#[serde(rename_all = "kebab-case")]
289#[non_exhaustive]
290pub enum SandboxProfile {
291    /// Read-only to `allow_read` paths, no writes, no network.
292    ReadOnly,
293    /// Read/write to configured paths; network egress blocked.
294    #[default]
295    Workspace,
296    /// Workspace-level filesystem access plus unrestricted network egress.
297    #[serde(rename = "network-allow-all", alias = "network")]
298    NetworkAllowAll,
299    /// Sandbox disabled. The subprocess inherits the parent's full capabilities.
300    Off,
301}
302
303fn default_sandbox_profile() -> SandboxProfile {
304    SandboxProfile::Workspace
305}
306
307/// Backend used to enforce OS-level sandboxing.
308///
309/// Serialises with `kebab-case` names so TOML values match the original string convention
310/// (`"auto"`, `"seatbelt"`, `"landlock-bwrap"`, `"noop"`).
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
312#[serde(rename_all = "kebab-case")]
313#[non_exhaustive]
314pub enum SandboxBackend {
315    /// Automatically select the best available backend for the current OS.
316    #[default]
317    Auto,
318    /// macOS `sandbox-exec` (Seatbelt) profile.
319    Seatbelt,
320    /// Linux Landlock + bubblewrap combination.
321    LandlockBwrap,
322    /// Disable sandboxing (testing / unsupported platforms).
323    Noop,
324}
325
326/// OS-level subprocess sandbox configuration (`[tools.sandbox]` TOML section).
327#[derive(Debug, Clone, Deserialize, Serialize)]
328pub struct SandboxConfig {
329    /// Enable OS-level sandbox. Default: `false`.
330    #[serde(default)]
331    pub enabled: bool,
332    /// Enforcement profile controlling the baseline restrictions.
333    #[serde(default = "default_sandbox_profile")]
334    pub profile: SandboxProfile,
335    /// Additional paths granted read access.
336    #[serde(default)]
337    pub allow_read: Vec<PathBuf>,
338    /// Additional paths granted write access.
339    #[serde(default)]
340    pub allow_write: Vec<PathBuf>,
341    /// When `true`, sandbox initialization failure aborts startup (fail-closed). Default: `true`.
342    #[serde(default = "default_true")]
343    pub strict: bool,
344    /// OS backend used to enforce sandboxing.
345    ///
346    /// Accepts `"auto"`, `"seatbelt"`, `"landlock-bwrap"`, or `"noop"` in TOML.
347    #[serde(default)]
348    pub backend: SandboxBackend,
349    /// Hostnames denied network egress from sandboxed subprocesses.
350    #[serde(default)]
351    pub denied_domains: Vec<String>,
352    /// When `true`, failure to activate an effective OS sandbox aborts startup.
353    #[serde(default)]
354    pub fail_if_unavailable: bool,
355}
356
357impl Default for SandboxConfig {
358    fn default() -> Self {
359        Self {
360            enabled: false,
361            profile: default_sandbox_profile(),
362            allow_read: Vec::new(),
363            allow_write: Vec::new(),
364            strict: true,
365            backend: SandboxBackend::Auto,
366            denied_domains: Vec::new(),
367            fail_if_unavailable: false,
368        }
369    }
370}
371
372// ── Output filter config ─────────────────────────────────────────────────────
373
374/// Configuration for tool output security filter.
375#[derive(Debug, Clone, Deserialize, Serialize)]
376pub struct SecurityFilterConfig {
377    /// Enable security filtering. Default: `true`.
378    #[serde(default = "default_true")]
379    pub enabled: bool,
380    /// Additional regex patterns to block in tool output.
381    #[serde(default)]
382    pub extra_patterns: Vec<String>,
383}
384
385impl Default for SecurityFilterConfig {
386    fn default() -> Self {
387        Self {
388            enabled: true,
389            extra_patterns: Vec::new(),
390        }
391    }
392}
393
394/// Configuration for output filters.
395#[derive(Debug, Clone, Deserialize, Serialize)]
396pub struct FilterConfig {
397    /// Master switch for output filtering. Default: `true`.
398    #[serde(default = "default_true")]
399    pub enabled: bool,
400    /// Security filter settings.
401    #[serde(default)]
402    pub security: SecurityFilterConfig,
403    /// Directory containing a `filters.toml` override file.
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub filters_path: Option<PathBuf>,
406}
407
408impl Default for FilterConfig {
409    fn default() -> Self {
410        Self {
411            enabled: true,
412            security: SecurityFilterConfig::default(),
413            filters_path: None,
414        }
415    }
416}
417
418// ── ToolsConfig sub-types ────────────────────────────────────────────────────
419
420fn default_overflow_threshold() -> usize {
421    50_000
422}
423
424fn default_retention_days() -> u64 {
425    7
426}
427
428fn default_max_overflow_bytes() -> usize {
429    10 * 1024 * 1024
430}
431
432fn default_max_per_call_override() -> usize {
433    131_072
434}
435
436/// Configuration for large tool response offload to `SQLite`.
437#[derive(Debug, Clone, Deserialize, Serialize)]
438pub struct OverflowConfig {
439    /// Character threshold above which tool output is offloaded. Default: `50000`.
440    #[serde(default = "default_overflow_threshold")]
441    pub threshold: usize,
442    /// Days to retain offloaded entries. Default: `7`.
443    #[serde(default = "default_retention_days")]
444    pub retention_days: u64,
445    /// Maximum bytes per overflow entry. `0` means unlimited. Default: `10 MiB`.
446    #[serde(default = "default_max_overflow_bytes")]
447    pub max_overflow_bytes: usize,
448    /// Hard ceiling (in chars) on a per-call result-size override that an MCP
449    /// server may request via `_meta["zeph/maxResultSizeChars"]`. The server's
450    /// request is clamped to `min(requested, max_per_call_override)` and may only
451    /// raise the effective limit above [`threshold`](Self::threshold), never lower
452    /// it. A value at or below `threshold` disables per-call overrides entirely.
453    /// Raising the effective limit also enlarges how much of that call's output
454    /// counts against the turn's context budget, not just summarizer cost.
455    /// Default: `131072` (128 KiB).
456    ///
457    /// Note: unlike [`max_overflow_bytes`](Self::max_overflow_bytes), `0` here
458    /// does NOT mean "unlimited" — it disables overrides (the clamp floors at
459    /// `threshold`).
460    ///
461    /// Note: for sanitized (external/untrusted, `:`-qualified) MCP output, the effective
462    /// in-context ceiling under default config is actually `zeph-sanitizer`'s
463    /// `ContentIsolationConfig::max_content_size` (65536 bytes by default), which is
464    /// smaller than this field's 131072-char default and caps what reaches the LLM
465    /// downstream of this clamp — a fail-safe (lower, not higher) bound, not a hole.
466    #[serde(default = "default_max_per_call_override")]
467    pub max_per_call_override: usize,
468}
469
470impl Default for OverflowConfig {
471    fn default() -> Self {
472        Self {
473            threshold: default_overflow_threshold(),
474            retention_days: default_retention_days(),
475            max_overflow_bytes: default_max_overflow_bytes(),
476            max_per_call_override: default_max_per_call_override(),
477        }
478    }
479}
480
481fn default_anomaly_window() -> usize {
482    10
483}
484
485fn default_anomaly_error_threshold() -> f64 {
486    0.5
487}
488
489fn default_anomaly_critical_threshold() -> f64 {
490    0.8
491}
492
493/// Configuration for the sliding-window anomaly detector.
494#[derive(Debug, Clone, Deserialize, Serialize)]
495pub struct AnomalyConfig {
496    /// Enable the anomaly detector. Default: `true`.
497    #[serde(default = "default_true")]
498    pub enabled: bool,
499    /// Number of recent tool calls in the sliding window. Default: `10`.
500    #[serde(default = "default_anomaly_window")]
501    pub window_size: usize,
502    /// Error-rate fraction triggering a WARN. Default: `0.5`.
503    #[serde(default = "default_anomaly_error_threshold")]
504    pub error_threshold: f64,
505    /// Error-rate fraction triggering a CRIT. Default: `0.8`.
506    #[serde(default = "default_anomaly_critical_threshold")]
507    pub critical_threshold: f64,
508    /// Emit a WARN when a reasoning model produces a quality failure. Default: `true`.
509    #[serde(default = "default_true")]
510    pub reasoning_model_warning: bool,
511}
512
513impl Default for AnomalyConfig {
514    fn default() -> Self {
515        Self {
516            enabled: true,
517            window_size: default_anomaly_window(),
518            error_threshold: default_anomaly_error_threshold(),
519            critical_threshold: default_anomaly_critical_threshold(),
520            reasoning_model_warning: true,
521        }
522    }
523}
524
525fn default_cache_ttl_secs() -> u64 {
526    300
527}
528
529/// Configuration for the tool result cache.
530#[derive(Debug, Clone, Deserialize, Serialize)]
531pub struct ResultCacheConfig {
532    /// Whether caching is enabled. Default: `true`.
533    #[serde(default = "default_true")]
534    pub enabled: bool,
535    /// Time-to-live in seconds. `0` means entries never expire. Default: `300`.
536    #[serde(default = "default_cache_ttl_secs")]
537    pub ttl_secs: u64,
538}
539
540impl Default for ResultCacheConfig {
541    fn default() -> Self {
542        Self {
543            enabled: true,
544            ttl_secs: default_cache_ttl_secs(),
545        }
546    }
547}
548
549fn default_tafc_complexity_threshold() -> f64 {
550    0.6
551}
552
553/// Configuration for Think-Augmented Function Calling (TAFC).
554#[derive(Debug, Clone, Deserialize, Serialize)]
555pub struct TafcConfig {
556    /// Enable TAFC schema augmentation. Default: `false`.
557    #[serde(default)]
558    pub enabled: bool,
559    /// Complexity threshold tau in [0.0, 1.0]; tools >= tau are augmented. Default: `0.6`.
560    #[serde(default = "default_tafc_complexity_threshold")]
561    pub complexity_threshold: f64,
562}
563
564impl Default for TafcConfig {
565    fn default() -> Self {
566        Self {
567            enabled: false,
568            complexity_threshold: default_tafc_complexity_threshold(),
569        }
570    }
571}
572
573impl TafcConfig {
574    /// Validate and clamp `complexity_threshold` to [0.0, 1.0]. Resets NaN/Infinity to 0.6.
575    #[must_use]
576    pub fn validated(mut self) -> Self {
577        if self.complexity_threshold.is_finite() {
578            self.complexity_threshold = self.complexity_threshold.clamp(0.0, 1.0);
579        } else {
580            self.complexity_threshold = 0.6;
581        }
582        self
583    }
584}
585
586fn default_utility_exempt_tools() -> Vec<String> {
587    vec!["invoke_skill".to_string(), "load_skill".to_string()]
588}
589
590fn default_utility_threshold() -> f32 {
591    0.1
592}
593
594fn default_utility_gain_weight() -> f32 {
595    1.0
596}
597
598fn default_utility_cost_weight() -> f32 {
599    0.5
600}
601
602fn default_utility_redundancy_weight() -> f32 {
603    0.3
604}
605
606fn default_utility_uncertainty_bonus() -> f32 {
607    0.2
608}
609
610/// Configuration for utility-guided tool dispatch.
611#[derive(Debug, Clone, Deserialize, Serialize)]
612#[serde(default)]
613pub struct UtilityScoringConfig {
614    /// Enable utility-guided gating. Default: `false`.
615    pub enabled: bool,
616    /// Minimum utility score required to execute a tool call. Default: `0.1`.
617    #[serde(default = "default_utility_threshold")]
618    pub threshold: f32,
619    /// Weight for the estimated gain component. Must be >= 0. Default: `1.0`.
620    #[serde(default = "default_utility_gain_weight")]
621    pub gain_weight: f32,
622    /// Weight for the step cost component. Must be >= 0. Default: `0.5`.
623    #[serde(default = "default_utility_cost_weight")]
624    pub cost_weight: f32,
625    /// Weight for the redundancy penalty. Must be >= 0. Default: `0.3`.
626    #[serde(default = "default_utility_redundancy_weight")]
627    pub redundancy_weight: f32,
628    /// Weight for the exploration bonus. Must be >= 0. Default: `0.2`.
629    #[serde(default = "default_utility_uncertainty_bonus")]
630    pub uncertainty_bonus: f32,
631    /// Tool names that bypass the utility gate unconditionally.
632    #[serde(default = "default_utility_exempt_tools")]
633    pub exempt_tools: Vec<String>,
634    /// Consecutive low-utility calls before early-stopping the loop. 0 = disabled.
635    ///
636    /// Exempt tools (`invoke_skill`, `load_skill`) do not count toward this window.
637    /// The counter resets between outer loop iterations.
638    #[serde(default)]
639    pub utility_window: usize,
640    /// Tool names that always receive the `0.75` "direct action" gain tier, matching
641    /// `diagnostics`/`edit`/etc in the built-in `default_gain` table.
642    ///
643    /// Opt-in override for tool ids the built-in table has no entry for — most notably
644    /// MCP-registered tools, whose ids are `{server_id}_{name}` (see
645    /// `McpTool::sanitized_id`) and therefore never match a hardcoded name. Without an
646    /// entry here, such a tool falls to the generic `0.5` bucket and can stall behind a
647    /// `Retrieve -> redundant retry -> vetoed` cycle on its first call (#5659). Default:
648    /// empty (no behavior change for existing configs).
649    #[serde(default)]
650    pub high_gain_tools: Vec<String>,
651}
652
653impl Default for UtilityScoringConfig {
654    fn default() -> Self {
655        Self {
656            enabled: false,
657            threshold: default_utility_threshold(),
658            gain_weight: default_utility_gain_weight(),
659            cost_weight: default_utility_cost_weight(),
660            redundancy_weight: default_utility_redundancy_weight(),
661            uncertainty_bonus: default_utility_uncertainty_bonus(),
662            exempt_tools: default_utility_exempt_tools(),
663            utility_window: 0,
664            high_gain_tools: Vec::new(),
665        }
666    }
667}
668
669impl UtilityScoringConfig {
670    /// Validate that all weights and threshold are non-negative and finite.
671    ///
672    /// # Errors
673    ///
674    /// Returns a description of the first invalid field found.
675    #[must_use = "validation result must be checked"]
676    pub fn validate(&self) -> Result<(), String> {
677        let fields = [
678            ("threshold", self.threshold),
679            ("gain_weight", self.gain_weight),
680            ("cost_weight", self.cost_weight),
681            ("redundancy_weight", self.redundancy_weight),
682            ("uncertainty_bonus", self.uncertainty_bonus),
683        ];
684        for (name, val) in fields {
685            if !val.is_finite() {
686                return Err(format!("[tools.utility] {name} must be finite, got {val}"));
687            }
688            if val < 0.0 {
689                return Err(format!("[tools.utility] {name} must be >= 0, got {val}"));
690            }
691        }
692        Ok(())
693    }
694}
695
696/// Dependency specification for a single tool.
697#[derive(Debug, Clone, Default, Deserialize, Serialize)]
698pub struct ToolDependency {
699    /// Hard prerequisites: tool is hidden until ALL of these have completed successfully.
700    #[serde(default, skip_serializing_if = "Vec::is_empty")]
701    pub requires: Vec<String>,
702    /// Soft prerequisites: tool gets a similarity boost when these have completed.
703    #[serde(default, skip_serializing_if = "Vec::is_empty")]
704    pub prefers: Vec<String>,
705}
706
707fn default_boost_per_dep() -> f32 {
708    0.15
709}
710
711fn default_max_total_boost() -> f32 {
712    0.2
713}
714
715/// Configuration for the tool dependency graph feature.
716#[derive(Debug, Clone, Deserialize, Serialize)]
717pub struct DependencyConfig {
718    /// Whether dependency gating is enabled. Default: `false`.
719    #[serde(default)]
720    pub enabled: bool,
721    /// Similarity boost added per satisfied `prefers` dependency. Default: `0.15`.
722    #[serde(default = "default_boost_per_dep")]
723    pub boost_per_dep: f32,
724    /// Maximum total boost applied regardless of how many `prefers` deps are met. Default: `0.2`.
725    #[serde(default = "default_max_total_boost")]
726    pub max_total_boost: f32,
727    /// Per-tool dependency rules. Key is `tool_id`.
728    #[serde(default)]
729    pub rules: HashMap<String, ToolDependency>,
730}
731
732impl Default for DependencyConfig {
733    fn default() -> Self {
734        Self {
735            enabled: false,
736            boost_per_dep: default_boost_per_dep(),
737            max_total_boost: default_max_total_boost(),
738            rules: HashMap::new(),
739        }
740    }
741}
742
743fn default_retry_max_attempts() -> usize {
744    2
745}
746
747fn default_retry_base_ms() -> u64 {
748    500
749}
750
751fn default_retry_max_ms() -> u64 {
752    5_000
753}
754
755fn default_retry_budget_secs() -> u64 {
756    30
757}
758
759/// Configuration for tool error retry behavior.
760#[derive(Debug, Clone, Deserialize, Serialize)]
761pub struct RetryConfig {
762    /// Maximum retry attempts for transient errors per tool call. `0` = disabled.
763    #[serde(default = "default_retry_max_attempts")]
764    pub max_attempts: usize,
765    /// Base delay (ms) for exponential backoff.
766    #[serde(default = "default_retry_base_ms")]
767    pub base_ms: u64,
768    /// Maximum delay cap (ms) for exponential backoff.
769    #[serde(default = "default_retry_max_ms")]
770    pub max_ms: u64,
771    /// Maximum wall-clock time (seconds) for all retries of a single tool call. `0` = unlimited.
772    #[serde(default = "default_retry_budget_secs")]
773    pub budget_secs: u64,
774    /// Provider name for LLM-based parameter reformatting on `InvalidParameters`/`TypeMismatch`.
775    /// Empty string = disabled.
776    #[serde(default)]
777    pub parameter_reformat_provider: ProviderName,
778}
779
780impl Default for RetryConfig {
781    fn default() -> Self {
782        Self {
783            max_attempts: default_retry_max_attempts(),
784            base_ms: default_retry_base_ms(),
785            max_ms: default_retry_max_ms(),
786            budget_secs: default_retry_budget_secs(),
787            parameter_reformat_provider: ProviderName::default(),
788        }
789    }
790}
791
792/// Fixed fallback timeout (ms) used for cloud policy providers, and whenever the
793/// provider kind cannot be determined.
794fn default_adversarial_timeout_ms() -> u64 {
795    3_000
796}
797
798/// Timeout (ms) used for local policy providers (Ollama, Candle, or any other
799/// locally-hosted model). Local inference routinely takes 10-30s+ per completion,
800/// far above the fixed default that was tuned for cloud APIs — see #5870. Set with
801/// margin above the worst-case latency observed in #5870's own reproduction
802/// (`qwen2.5:7b` took up to `31928` ms): 45s clears that by ~13s (~41%) so the
803/// fix closes the failure window instead of merely narrowing it.
804const LOCAL_PROVIDER_ADVERSARIAL_TIMEOUT_MS: u64 = 45_000;
805
806/// Resolve the effective adversarial policy timeout for a resolved provider kind.
807///
808/// `provider_kind` is the value returned by `AnyProvider::provider_kind_str()`:
809/// `"ollama"` / `"candle"` / `"local"` for locally-hosted inference, `"cloud"` for
810/// metered API providers. Local providers get a much longer fail-closed budget,
811/// since a fixed 3s timeout made the fail-closed adversarial gate deny effectively
812/// every tool call when `policy_provider` pointed at a local Ollama model (#5870).
813///
814/// This classification is keyed on the provider's configured **type**
815/// (`[[llm.providers]].type`), not on whether its endpoint happens to be local.
816/// A `type = "openai"`/`"claude"` entry pointed at a self-hosted or localhost
817/// `base_url` (e.g. an OpenAI-compatible proxy in front of a local model) still
818/// resolves to `"cloud"` here and gets the short 3s budget. Operators running a
819/// cloud-provider-typed client against a slow self-hosted endpoint should set
820/// [`AdversarialPolicyConfig::timeout_ms`] explicitly rather than relying on
821/// auto-scaling.
822///
823/// Only used when [`AdversarialPolicyConfig::timeout_ms`] is left unset — an
824/// explicit value always takes precedence over provider-based scaling.
825///
826/// # Examples
827///
828/// ```
829/// use zeph_config::tools::adversarial_timeout_for_provider_kind;
830///
831/// assert_eq!(adversarial_timeout_for_provider_kind("ollama"), 45_000);
832/// assert_eq!(adversarial_timeout_for_provider_kind("cloud"), 3_000);
833/// ```
834#[must_use]
835pub fn adversarial_timeout_for_provider_kind(provider_kind: &str) -> u64 {
836    if matches!(provider_kind, "ollama" | "candle" | "local") {
837        LOCAL_PROVIDER_ADVERSARIAL_TIMEOUT_MS
838    } else {
839        default_adversarial_timeout_ms()
840    }
841}
842
843/// Configuration for the LLM-based adversarial policy agent.
844#[derive(Debug, Clone, Deserialize, Serialize)]
845pub struct AdversarialPolicyConfig {
846    /// Enable the adversarial policy agent. Default: `false`.
847    #[serde(default)]
848    pub enabled: bool,
849    /// Provider name for the policy validation LLM.
850    #[serde(default)]
851    pub policy_provider: ProviderName,
852    /// Path to a plain-text policy file.
853    pub policy_file: Option<String>,
854    /// Whether to allow tool calls when the policy LLM fails. Default: `false` (fail-closed).
855    #[serde(default)]
856    pub fail_open: bool,
857    /// Timeout in milliseconds for a single policy LLM call.
858    ///
859    /// When unset (the default), the effective timeout is derived at startup from the
860    /// resolved `policy_provider`'s kind via [`adversarial_timeout_for_provider_kind`]:
861    /// local providers get a much longer fail-closed budget than cloud providers. Set
862    /// this explicitly to override auto-scaling with a fixed value.
863    #[serde(default)]
864    pub timeout_ms: Option<u64>,
865    /// Tool names always allowed through the adversarial policy gate.
866    #[serde(default = "AdversarialPolicyConfig::default_exempt_tools")]
867    pub exempt_tools: Vec<String>,
868}
869
870impl Default for AdversarialPolicyConfig {
871    fn default() -> Self {
872        Self {
873            enabled: false,
874            policy_provider: ProviderName::default(),
875            policy_file: None,
876            fail_open: false,
877            timeout_ms: None,
878            exempt_tools: Self::default_exempt_tools(),
879        }
880    }
881}
882
883impl AdversarialPolicyConfig {
884    #[must_use]
885    pub fn default_exempt_tools() -> Vec<String> {
886        vec![
887            "memory_save".into(),
888            "memory_search".into(),
889            "read_overflow".into(),
890            "load_skill".into(),
891            "invoke_skill".into(),
892            "schedule_deferred".into(),
893            // Read-only scheduler intrinsic must never be blocked by the adversarial
894            // probe: it carries no side-effects and the embed provider may be unavailable.
895            "list_tasks".into(),
896        ]
897    }
898}
899
900/// Per-path read allow/deny sandbox for the file tool.
901///
902/// Evaluation order: deny-then-allow. If a path matches `deny_read` and does NOT
903/// match `allow_read`, access is denied. Empty `deny_read` means no read restrictions.
904#[derive(Debug, Clone, Default, Deserialize, Serialize)]
905pub struct FileConfig {
906    /// Glob patterns for paths denied for reading. Evaluated first.
907    #[serde(default)]
908    pub deny_read: Vec<String>,
909    /// Glob patterns for paths allowed for reading. Evaluated second (overrides deny).
910    #[serde(default)]
911    pub allow_read: Vec<String>,
912}
913
914/// OAP-style declarative authorization config.
915#[derive(Debug, Clone, Default, Deserialize, Serialize)]
916pub struct AuthorizationConfig {
917    /// Enable OAP authorization checks. Default: `false`.
918    #[serde(default)]
919    pub enabled: bool,
920    /// Per-tool authorization rules appended after `[tools.policy]` rules at startup.
921    #[serde(default)]
922    pub rules: Vec<PolicyRuleConfig>,
923}
924
925/// Audit log destination.
926///
927/// Deserializes from a string in TOML: `"stdout"`, `"stderr"`, or a file path.
928#[derive(Debug, Clone, PartialEq, Eq, Default)]
929#[non_exhaustive]
930pub enum AuditDestination {
931    /// Write audit entries to standard output.
932    #[default]
933    Stdout,
934    /// Write audit entries to standard error.
935    Stderr,
936    /// Write audit entries to the given file path (appended, mode 0o600).
937    File(std::path::PathBuf),
938}
939
940impl AuditDestination {
941    /// Returns `true` if the destination is `stdout`.
942    #[must_use]
943    pub fn is_stdout(&self) -> bool {
944        matches!(self, Self::Stdout)
945    }
946}
947
948impl serde::Serialize for AuditDestination {
949    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
950        match self {
951            Self::Stdout => s.serialize_str("stdout"),
952            Self::Stderr => s.serialize_str("stderr"),
953            Self::File(p) => s.serialize_str(&p.display().to_string()),
954        }
955    }
956}
957
958impl<'de> serde::Deserialize<'de> for AuditDestination {
959    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
960        let s = String::deserialize(d)?;
961        Ok(match s.as_str() {
962            "stdout" => Self::Stdout,
963            "stderr" => Self::Stderr,
964            path => Self::File(std::path::PathBuf::from(path)),
965        })
966    }
967}
968
969/// Configuration for audit logging of tool executions.
970#[derive(Debug, Deserialize, Serialize)]
971pub struct AuditConfig {
972    /// Enable audit logging. Default: `true`.
973    #[serde(default = "default_true")]
974    pub enabled: bool,
975    /// Log destination. Default: [`AuditDestination::Stdout`].
976    #[serde(default)]
977    pub destination: AuditDestination,
978    /// When `true`, log a per-tool risk summary at startup. Default: `false`.
979    #[serde(default)]
980    pub tool_risk_summary: bool,
981}
982
983impl Default for AuditConfig {
984    fn default() -> Self {
985        Self {
986            enabled: true,
987            destination: AuditDestination::default(),
988            tool_risk_summary: false,
989        }
990    }
991}
992
993fn default_timeout() -> u64 {
994    30
995}
996
997fn default_confirm_patterns() -> Vec<String> {
998    vec![
999        "rm ".into(),
1000        "git push -f".into(),
1001        "git push --force".into(),
1002        "drop table".into(),
1003        "drop database".into(),
1004        "truncate ".into(),
1005        "$(".into(),
1006        "`".into(),
1007        "<(".into(),
1008        ">(".into(),
1009        "<<<".into(),
1010        "eval ".into(),
1011    ]
1012}
1013
1014fn default_max_background_runs() -> usize {
1015    8
1016}
1017
1018fn default_background_timeout_secs() -> u64 {
1019    1800
1020}
1021
1022fn default_max_checkpoints() -> usize {
1023    20
1024}
1025
1026/// Shell-specific configuration: timeout, command blocklist, and allowlist overrides.
1027#[derive(Debug, Clone, Deserialize, Serialize)]
1028#[allow(clippy::struct_excessive_bools)]
1029pub struct ShellConfig {
1030    /// Shell command timeout in seconds. Default: `30`.
1031    #[serde(default = "default_timeout")]
1032    pub timeout: u64,
1033    /// Commands blocked from execution.
1034    #[serde(default)]
1035    pub blocked_commands: Vec<String>,
1036    /// Commands explicitly allowed (overrides blocklist).
1037    #[serde(default)]
1038    pub allowed_commands: Vec<String>,
1039    /// Filesystem paths the shell is permitted to access.
1040    #[serde(default)]
1041    pub allowed_paths: Vec<String>,
1042    /// Allow outbound network from shell. Default: `true`.
1043    #[serde(default = "default_true")]
1044    pub allow_network: bool,
1045    /// Patterns that trigger a confirmation prompt before execution.
1046    #[serde(default = "default_confirm_patterns")]
1047    pub confirm_patterns: Vec<String>,
1048    /// Environment variable name prefixes to strip from subprocess environment.
1049    #[serde(default = "ShellConfig::default_env_blocklist")]
1050    pub env_blocklist: Vec<String>,
1051    /// Enable transactional mode: snapshot files before write commands. Default: `false`.
1052    #[serde(default)]
1053    pub transactional: bool,
1054    /// Glob patterns for paths eligible for snapshotting.
1055    #[serde(default)]
1056    pub transaction_scope: Vec<String>,
1057    /// Automatically rollback when exit code >= 2. Default: `false`.
1058    #[serde(default)]
1059    pub auto_rollback: bool,
1060    /// Exit codes that trigger auto-rollback.
1061    #[serde(default)]
1062    pub auto_rollback_exit_codes: Vec<i32>,
1063    /// When `true`, snapshot failure aborts execution. Default: `false`.
1064    #[serde(default)]
1065    pub snapshot_required: bool,
1066    /// Maximum cumulative bytes for transaction snapshots. `0` = unlimited.
1067    #[serde(default)]
1068    pub max_snapshot_bytes: u64,
1069    /// Maximum concurrent background shell runs. Default: `8`.
1070    #[serde(default = "default_max_background_runs")]
1071    pub max_background_runs: usize,
1072    /// Timeout in seconds for each background shell run. Default: `1800`.
1073    #[serde(default = "default_background_timeout_secs")]
1074    pub background_timeout_secs: u64,
1075    /// Cumulative risk score threshold for multi-step chain blocking. Default: `0.7`.
1076    ///
1077    /// When the `RiskChainAccumulator` (zeph-tools) exceeds this score within a single turn,
1078    /// the command is blocked. Set to `None` to use the built-in default of `0.7`.
1079    #[serde(default)]
1080    pub risk_chain_threshold: Option<f32>,
1081    /// Number of turns a recorded tool call stays "live" for `RiskChainAccumulator` cross-turn
1082    /// multi-step chain detection (#6603). Set to `None` to use the built-in default of `3`
1083    /// (see `zeph_tools::risk_chain` module docs for the rationale behind that default, and
1084    /// why it is narrower than `[security.trajectory] window_turns`'s default of `8`).
1085    #[serde(default)]
1086    pub risk_chain_window_turns: Option<u64>,
1087    /// Enable session-scoped checkpoint history for `/undo` and `/redo`. Default: `false`.
1088    ///
1089    /// When `true`, file snapshots are captured before each write command and stored
1090    /// in an in-memory stack for the duration of the session. Checkpoints are lost
1091    /// when the agent process exits.
1092    #[serde(default)]
1093    pub checkpoints_enabled: bool,
1094    /// Maximum number of checkpoints retained in the undo stack. Default: `20`.
1095    ///
1096    /// When the stack reaches this limit, the oldest entry is evicted to make room.
1097    /// Set to `0` for no limit (not recommended for long-running sessions).
1098    #[serde(default = "default_max_checkpoints")]
1099    pub max_checkpoints: usize,
1100}
1101
1102impl Default for ShellConfig {
1103    fn default() -> Self {
1104        Self {
1105            timeout: default_timeout(),
1106            blocked_commands: Vec::new(),
1107            allowed_commands: Vec::new(),
1108            allowed_paths: Vec::new(),
1109            allow_network: true,
1110            confirm_patterns: default_confirm_patterns(),
1111            env_blocklist: Self::default_env_blocklist(),
1112            transactional: false,
1113            transaction_scope: Vec::new(),
1114            auto_rollback: false,
1115            auto_rollback_exit_codes: Vec::new(),
1116            snapshot_required: false,
1117            max_snapshot_bytes: 0,
1118            max_background_runs: default_max_background_runs(),
1119            background_timeout_secs: default_background_timeout_secs(),
1120            risk_chain_threshold: None,
1121            risk_chain_window_turns: None,
1122            checkpoints_enabled: false,
1123            max_checkpoints: default_max_checkpoints(),
1124        }
1125    }
1126}
1127
1128impl ShellConfig {
1129    /// Default environment variable prefixes to strip from subprocess environment.
1130    #[must_use]
1131    pub fn default_env_blocklist() -> Vec<String> {
1132        vec![
1133            "ZEPH_".into(),
1134            "AWS_".into(),
1135            "AZURE_".into(),
1136            "GCP_".into(),
1137            "GOOGLE_".into(),
1138            "OPENAI_".into(),
1139            "ANTHROPIC_".into(),
1140            "HF_".into(),
1141            "HUGGING".into(),
1142        ]
1143    }
1144}
1145
1146fn default_scrape_timeout() -> u64 {
1147    15
1148}
1149
1150fn default_max_body_bytes() -> usize {
1151    4_194_304
1152}
1153
1154fn default_ipi_filter_threshold() -> f32 {
1155    0.6
1156}
1157
1158/// Configuration for the web scrape tool.
1159#[derive(Debug, Deserialize, Serialize)]
1160pub struct ScrapeConfig {
1161    /// Timeout in seconds for scrape requests. Default: `15`.
1162    #[serde(default = "default_scrape_timeout")]
1163    pub timeout: u64,
1164    /// Maximum response body bytes. Default: `4 MiB`.
1165    #[serde(default = "default_max_body_bytes")]
1166    pub max_body_bytes: usize,
1167    /// Domain allowlist. Empty = all public domains allowed.
1168    #[serde(default)]
1169    pub allowed_domains: Vec<String>,
1170    /// Domain denylist. Always enforced, regardless of allowlist state.
1171    #[serde(default)]
1172    pub denied_domains: Vec<String>,
1173    /// IPI filter score threshold. Responses with score >= this value get a warning
1174    /// prepended and injection fragments replaced with `[FILTERED]`. Default: `0.6`.
1175    #[serde(default = "default_ipi_filter_threshold")]
1176    pub ipi_filter_threshold: f32,
1177}
1178
1179impl Default for ScrapeConfig {
1180    fn default() -> Self {
1181        Self {
1182            timeout: default_scrape_timeout(),
1183            max_body_bytes: default_max_body_bytes(),
1184            allowed_domains: Vec::new(),
1185            denied_domains: Vec::new(),
1186            ipi_filter_threshold: default_ipi_filter_threshold(),
1187        }
1188    }
1189}
1190
1191fn default_search_backend() -> String {
1192    "brave".to_owned()
1193}
1194
1195fn default_search_vault_key() -> String {
1196    "ZEPH_WEB_SEARCH_API_KEY".to_owned()
1197}
1198
1199fn default_search_endpoint() -> String {
1200    "https://api.search.brave.com/res/v1/web/search".to_owned()
1201}
1202
1203fn default_search_max_results() -> usize {
1204    10
1205}
1206
1207fn default_search_timeout() -> u64 {
1208    15
1209}
1210
1211/// Configuration for the native query-based `web_search` tool (`[tools.search]`).
1212///
1213/// Runtime-gated, not cargo-feature-gated: the tool compiles unconditionally but is only
1214/// advertised to the LLM when `enabled` is `true` AND a backend resolves (see
1215/// `zeph_tools::search::SearchBackend::from_config`). Disabled by default. The API key is
1216/// resolved exclusively from the age vault under `api_key_vault_key` — never from an
1217/// environment variable or a literal in this struct. See
1218/// `specs/006-tools/006-1-web-search.md`.
1219#[derive(Debug, Clone, Deserialize, Serialize)]
1220pub struct SearchConfig {
1221    /// Runtime gate. When `false`, `web_search` is never advertised to the LLM. Default: `false`.
1222    #[serde(default)]
1223    pub enabled: bool,
1224    /// `SearchBackend` variant selector (`zeph-tools`). Default: `"brave"`.
1225    #[serde(default = "default_search_backend")]
1226    pub backend: String,
1227    /// Age-vault key name the API key is resolved from. Never an environment variable.
1228    /// Default: `"ZEPH_WEB_SEARCH_API_KEY"`.
1229    #[serde(default = "default_search_vault_key")]
1230    pub api_key_vault_key: String,
1231    /// Search API endpoint. Override for a self-hosted/proxy/alternate backend.
1232    /// Default: the Brave Search API endpoint.
1233    #[serde(default = "default_search_endpoint")]
1234    pub endpoint: String,
1235    /// Cap on returned results. Default: `10`.
1236    #[serde(default = "default_search_max_results")]
1237    pub max_results: usize,
1238    /// Request timeout in seconds. Default: `15`.
1239    #[serde(default = "default_search_timeout")]
1240    pub timeout: u64,
1241}
1242
1243impl Default for SearchConfig {
1244    fn default() -> Self {
1245        Self {
1246            enabled: false,
1247            backend: default_search_backend(),
1248            api_key_vault_key: default_search_vault_key(),
1249            endpoint: default_search_endpoint(),
1250            max_results: default_search_max_results(),
1251            timeout: default_search_timeout(),
1252        }
1253    }
1254}
1255
1256/// Speculative tool execution mode.
1257#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
1258#[serde(rename_all = "kebab-case")]
1259#[non_exhaustive]
1260pub enum SpeculationMode {
1261    /// No speculation; uses existing synchronous path.
1262    #[default]
1263    Off,
1264    /// LLM-decoding level: fires tools when streaming partial JSON has all required fields.
1265    Decoding,
1266    /// Application-level pattern (PASTE): predicts top-K calls from `SQLite` history.
1267    Pattern,
1268    /// Both decoding and pattern speculation active.
1269    Both,
1270}
1271
1272/// Pattern-based (PASTE) speculative execution config.
1273#[derive(Debug, Clone, Deserialize, Serialize)]
1274pub struct SpeculativePatternConfig {
1275    /// Enable PASTE pattern learning and prediction. Default: `false`.
1276    #[serde(default)]
1277    pub enabled: bool,
1278    /// Minimum observed occurrences before a prediction is issued.
1279    #[serde(default = "default_min_observations")]
1280    pub min_observations: u32,
1281    /// Exponential decay half-life in days for pattern scoring.
1282    #[serde(default = "default_half_life_days")]
1283    pub half_life_days: f64,
1284    /// LLM provider name for optional reranking. Empty = disabled.
1285    #[serde(default)]
1286    pub rerank_provider: ProviderName,
1287}
1288
1289fn default_min_observations() -> u32 {
1290    5
1291}
1292
1293fn default_half_life_days() -> f64 {
1294    14.0
1295}
1296
1297impl Default for SpeculativePatternConfig {
1298    fn default() -> Self {
1299        Self {
1300            enabled: false,
1301            min_observations: default_min_observations(),
1302            half_life_days: default_half_life_days(),
1303            rerank_provider: ProviderName::default(),
1304        }
1305    }
1306}
1307
1308/// Shell command regex allowlist for speculative execution.
1309#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1310pub struct SpeculativeAllowlistConfig {
1311    /// Regexes matched against the full `bash` command string.
1312    #[serde(default)]
1313    pub shell: Vec<String>,
1314}
1315
1316fn default_max_in_flight() -> usize {
1317    4
1318}
1319
1320fn default_confidence_threshold() -> f32 {
1321    0.55
1322}
1323
1324fn default_max_wasted_per_minute() -> u64 {
1325    100
1326}
1327
1328fn default_ttl_seconds() -> u64 {
1329    30
1330}
1331
1332/// Top-level configuration for speculative tool execution.
1333#[derive(Debug, Clone, Deserialize, Serialize)]
1334pub struct SpeculativeConfig {
1335    /// Speculation mode. Default: `off`.
1336    #[serde(default)]
1337    pub mode: SpeculationMode,
1338    /// Maximum concurrent in-flight speculative tasks.
1339    #[serde(default = "default_max_in_flight")]
1340    pub max_in_flight: usize,
1341    /// Minimum confidence score [0, 1] to dispatch a speculative task.
1342    #[serde(default = "default_confidence_threshold")]
1343    pub confidence_threshold: f32,
1344    /// Circuit-breaker: disable speculation for 60 s when wasted ms exceeds this per minute.
1345    #[serde(default = "default_max_wasted_per_minute")]
1346    pub max_wasted_per_minute: u64,
1347    /// Per-handle wall-clock TTL in seconds before the handle is cancelled.
1348    #[serde(default = "default_ttl_seconds")]
1349    pub ttl_seconds: u64,
1350    /// Emit `AuditEntry` for speculative dispatches. Default: `true`.
1351    #[serde(default = "default_true")]
1352    pub audit: bool,
1353    /// PASTE pattern learning config.
1354    #[serde(default)]
1355    pub pattern: SpeculativePatternConfig,
1356    /// Per-executor command allowlists.
1357    #[serde(default)]
1358    pub allowlist: SpeculativeAllowlistConfig,
1359}
1360
1361impl Default for SpeculativeConfig {
1362    fn default() -> Self {
1363        Self {
1364            mode: SpeculationMode::Off,
1365            max_in_flight: default_max_in_flight(),
1366            confidence_threshold: default_confidence_threshold(),
1367            max_wasted_per_minute: default_max_wasted_per_minute(),
1368            ttl_seconds: default_ttl_seconds(),
1369            audit: true,
1370            pattern: SpeculativePatternConfig::default(),
1371            allowlist: SpeculativeAllowlistConfig::default(),
1372        }
1373    }
1374}
1375
1376/// Configuration for egress network event logging.
1377#[derive(Debug, Clone, Deserialize, Serialize)]
1378#[serde(default)]
1379#[allow(clippy::struct_excessive_bools)]
1380pub struct EgressConfig {
1381    /// Master switch for egress event emission. Default: `true`.
1382    pub enabled: bool,
1383    /// Emit events for requests blocked by SSRF/domain/scheme checks. Default: `true`.
1384    pub log_blocked: bool,
1385    /// Include `response_bytes` in the JSONL record. Default: `true`.
1386    pub log_response_bytes: bool,
1387    /// Show real hostname in TUI egress panel. Default: `true`.
1388    pub log_hosts_to_tui: bool,
1389}
1390
1391impl Default for EgressConfig {
1392    fn default() -> Self {
1393        Self {
1394            enabled: true,
1395            log_blocked: true,
1396            log_response_bytes: true,
1397            log_hosts_to_tui: true,
1398        }
1399    }
1400}
1401
1402// ── ToolCompressionConfig ─────────────────────────────────────────────────────
1403
1404fn default_compression_min_lines() -> usize {
1405    10
1406}
1407
1408fn default_compression_max_rules() -> u32 {
1409    200
1410}
1411
1412fn default_regex_compile_timeout_ms() -> u64 {
1413    500
1414}
1415
1416fn default_evolution_min_interval_secs() -> u64 {
1417    3600
1418}
1419
1420/// TACO self-evolving tool output compression configuration (`[tools.compression]` TOML section).
1421///
1422/// When enabled, a `RuleBasedCompressor` is wrapped around the root tool executor.
1423/// Rules are loaded from the `compression_rules` `SQLite` table and optionally evolved by an
1424/// LLM provider specified in `evolution_provider`.
1425///
1426/// # Example (TOML)
1427///
1428/// ```toml
1429/// [tools.compression]
1430/// enabled = true
1431/// evolution_provider = "fast"
1432/// min_lines_to_compress = 15
1433/// ```
1434#[derive(Debug, Clone, Deserialize, Serialize)]
1435#[serde(default)]
1436pub struct ToolCompressionConfig {
1437    /// Enable rule-based tool output compression. Default: `false`.
1438    pub enabled: bool,
1439    /// Minimum output line count before compression is attempted. Default: `10`.
1440    #[serde(default = "default_compression_min_lines")]
1441    pub min_lines_to_compress: usize,
1442    /// LLM provider name for self-evolution. Empty string = evolution disabled. Default: `""`.
1443    #[serde(default)]
1444    pub evolution_provider: ProviderName,
1445    /// Minimum interval in seconds between self-evolution runs. Default: `3600`.
1446    #[serde(default = "default_evolution_min_interval_secs")]
1447    pub evolution_min_interval_secs: u64,
1448    /// Maximum number of rules to keep in the DB (prune lowest-hit rules above this). Default: `200`.
1449    #[serde(default = "default_compression_max_rules")]
1450    pub max_rules: u32,
1451    /// Timeout in milliseconds for safe regex compilation. Default: `500`.
1452    #[serde(default = "default_regex_compile_timeout_ms")]
1453    pub regex_compile_timeout_ms: u64,
1454}
1455
1456impl Default for ToolCompressionConfig {
1457    fn default() -> Self {
1458        Self {
1459            enabled: false,
1460            min_lines_to_compress: default_compression_min_lines(),
1461            evolution_provider: ProviderName::default(),
1462            evolution_min_interval_secs: default_evolution_min_interval_secs(),
1463            max_rules: default_compression_max_rules(),
1464            regex_compile_timeout_ms: default_regex_compile_timeout_ms(),
1465        }
1466    }
1467}
1468
1469// ── ToolsConfig ───────────────────────────────────────────────────────────────
1470
1471/// Top-level configuration for tool execution.
1472///
1473/// Deserialized from `[tools]` in TOML. The `permission_policy()` method (which constructs
1474/// a runtime `PermissionPolicy`) lives in `zeph-tools` as a free function to avoid
1475/// importing runtime types into this leaf crate.
1476#[derive(Debug, Deserialize, Serialize)]
1477pub struct ToolsConfig {
1478    /// Enable all tools. When `false`, no tool definitions are sent to the LLM and the model
1479    /// cannot attempt any tool call. Default: `true`.
1480    #[serde(default = "default_true")]
1481    pub enabled: bool,
1482    /// Summarize long tool output before injection into context. Default: `true`.
1483    #[serde(default = "default_true")]
1484    pub summarize_output: bool,
1485    /// Shell tool configuration.
1486    #[serde(default)]
1487    pub shell: ShellConfig,
1488    /// Web scrape tool configuration.
1489    #[serde(default)]
1490    pub scrape: ScrapeConfig,
1491    /// Native query-based web search tool configuration.
1492    #[serde(default)]
1493    pub search: SearchConfig,
1494    /// Audit log configuration.
1495    #[serde(default)]
1496    pub audit: AuditConfig,
1497    /// Declarative permissions. Overrides legacy `shell.blocked_commands` when set.
1498    #[serde(default)]
1499    pub permissions: Option<PermissionsConfig>,
1500    /// Output filter configuration.
1501    #[serde(default)]
1502    pub filters: FilterConfig,
1503    /// Large response offload configuration.
1504    #[serde(default)]
1505    pub overflow: OverflowConfig,
1506    /// Sliding-window anomaly detector.
1507    #[serde(default)]
1508    pub anomaly: AnomalyConfig,
1509    /// Tool result cache.
1510    #[serde(default)]
1511    pub result_cache: ResultCacheConfig,
1512    /// Think-Augmented Function Calling.
1513    #[serde(default)]
1514    pub tafc: TafcConfig,
1515    /// Tool dependency graph.
1516    #[serde(default)]
1517    pub dependencies: DependencyConfig,
1518    /// Error retry configuration.
1519    #[serde(default)]
1520    pub retry: RetryConfig,
1521    /// Declarative policy compiler for tool call authorization.
1522    #[serde(default)]
1523    pub policy: PolicyConfig,
1524    /// LLM-based adversarial policy agent.
1525    #[serde(default)]
1526    pub adversarial_policy: AdversarialPolicyConfig,
1527    /// Utility-guided tool dispatch gate.
1528    #[serde(default)]
1529    pub utility: UtilityScoringConfig,
1530    /// Per-path read allow/deny sandbox for the file tool.
1531    #[serde(default)]
1532    pub file: FileConfig,
1533    /// OAP declarative pre-action authorization.
1534    #[serde(default)]
1535    pub authorization: AuthorizationConfig,
1536    /// Maximum tool calls allowed per agent session. `None` = unlimited.
1537    #[serde(default)]
1538    pub max_tool_calls_per_session: Option<u32>,
1539    /// Speculative tool execution configuration.
1540    #[serde(default)]
1541    pub speculative: SpeculativeConfig,
1542    /// OS-level subprocess sandbox configuration.
1543    #[serde(default)]
1544    pub sandbox: SandboxConfig,
1545    /// Egress network event logging configuration.
1546    #[serde(default)]
1547    pub egress: EgressConfig,
1548    /// TACO self-evolving tool output compression configuration.
1549    #[serde(default)]
1550    pub compression: ToolCompressionConfig,
1551}
1552
1553impl Default for ToolsConfig {
1554    fn default() -> Self {
1555        Self {
1556            enabled: true,
1557            summarize_output: true,
1558            shell: ShellConfig::default(),
1559            scrape: ScrapeConfig::default(),
1560            search: SearchConfig::default(),
1561            audit: AuditConfig::default(),
1562            permissions: None,
1563            filters: FilterConfig::default(),
1564            overflow: OverflowConfig::default(),
1565            anomaly: AnomalyConfig::default(),
1566            result_cache: ResultCacheConfig::default(),
1567            tafc: TafcConfig::default(),
1568            dependencies: DependencyConfig::default(),
1569            retry: RetryConfig::default(),
1570            policy: PolicyConfig::default(),
1571            adversarial_policy: AdversarialPolicyConfig::default(),
1572            utility: UtilityScoringConfig::default(),
1573            file: FileConfig::default(),
1574            authorization: AuthorizationConfig::default(),
1575            max_tool_calls_per_session: None,
1576            speculative: SpeculativeConfig::default(),
1577            sandbox: SandboxConfig::default(),
1578            egress: EgressConfig::default(),
1579            compression: ToolCompressionConfig::default(),
1580        }
1581    }
1582}
1583
1584#[cfg(test)]
1585mod tests {
1586    use super::*;
1587
1588    #[test]
1589    fn deserialize_default_config() {
1590        let toml_str = r#"
1591            enabled = true
1592
1593            [shell]
1594            timeout = 60
1595            blocked_commands = ["rm -rf /", "sudo"]
1596        "#;
1597
1598        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1599        assert!(config.enabled);
1600        assert_eq!(config.shell.timeout, 60);
1601        assert_eq!(config.shell.blocked_commands.len(), 2);
1602    }
1603
1604    #[test]
1605    fn empty_blocked_commands() {
1606        let config: ToolsConfig = toml::from_str(r"[shell]\ntimeout = 30\n").unwrap_or_default();
1607        assert!(config.enabled);
1608    }
1609
1610    #[test]
1611    fn default_tools_config() {
1612        let config = ToolsConfig::default();
1613        assert!(config.enabled);
1614        assert!(config.summarize_output);
1615        assert_eq!(config.shell.timeout, 30);
1616        assert!(config.shell.blocked_commands.is_empty());
1617        assert!(config.audit.enabled);
1618    }
1619
1620    #[test]
1621    fn audit_destination_serde_roundtrip() {
1622        let cases = [
1623            ("\"stdout\"", AuditDestination::Stdout),
1624            ("\"stderr\"", AuditDestination::Stderr),
1625            (
1626                "\"/var/log/audit.log\"",
1627                AuditDestination::File("/var/log/audit.log".into()),
1628            ),
1629        ];
1630        for (json_str, expected) in cases {
1631            let got: AuditDestination = serde_json::from_str(json_str).unwrap();
1632            assert_eq!(got, expected);
1633            let serialized = serde_json::to_string(&got).unwrap();
1634            let roundtrip: AuditDestination = serde_json::from_str(&serialized).unwrap();
1635            assert_eq!(roundtrip, expected);
1636        }
1637    }
1638
1639    #[test]
1640    fn audit_destination_toml_in_config() {
1641        let cases = [
1642            (
1643                r#"[audit]
1644destination = "stdout""#,
1645                AuditDestination::Stdout,
1646            ),
1647            (
1648                r#"[audit]
1649destination = "stderr""#,
1650                AuditDestination::Stderr,
1651            ),
1652            (
1653                r#"[audit]
1654destination = "/var/log/zeph-audit.log""#,
1655                AuditDestination::File("/var/log/zeph-audit.log".into()),
1656            ),
1657        ];
1658        for (toml_str, expected) in cases {
1659            let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1660            assert_eq!(config.audit.destination, expected);
1661        }
1662    }
1663
1664    #[test]
1665    fn policy_provider_serde_roundtrip() {
1666        let toml_str = r#"
1667            [policy]
1668            enabled = true
1669            policy_provider = "my-llm"
1670        "#;
1671        let config: ToolsConfig = toml::from_str(toml_str).unwrap();
1672        assert_eq!(config.policy.policy_provider.as_str(), "my-llm");
1673
1674        let json = serde_json::to_string(&config.policy).unwrap();
1675        let back: PolicyConfig = serde_json::from_str(&json).unwrap();
1676        assert_eq!(back.policy_provider.as_str(), "my-llm");
1677    }
1678
1679    #[test]
1680    fn policy_provider_default_is_empty() {
1681        let config = PolicyConfig::default();
1682        assert!(config.policy_provider.is_empty());
1683    }
1684
1685    #[test]
1686    fn utility_window_default_is_zero() {
1687        let config = UtilityScoringConfig::default();
1688        assert_eq!(config.utility_window, 0);
1689    }
1690
1691    #[test]
1692    fn utility_window_serde_roundtrip() {
1693        #[derive(serde::Deserialize)]
1694        struct Wrapper {
1695            utility_scoring: UtilityScoringConfig,
1696        }
1697
1698        let toml_str = "[utility_scoring]\nenabled = true\nutility_window = 3\n";
1699        let w: Wrapper = toml::from_str(toml_str).unwrap();
1700        assert_eq!(w.utility_scoring.utility_window, 3);
1701
1702        let json = serde_json::to_string(&w.utility_scoring).unwrap();
1703        let back: UtilityScoringConfig = serde_json::from_str(&json).unwrap();
1704        assert_eq!(back.utility_window, 3);
1705    }
1706
1707    #[test]
1708    fn high_gain_tools_default_is_empty() {
1709        let config = UtilityScoringConfig::default();
1710        assert!(config.high_gain_tools.is_empty());
1711    }
1712
1713    #[test]
1714    fn high_gain_tools_serde_roundtrip() {
1715        #[derive(serde::Deserialize)]
1716        struct Wrapper {
1717            utility_scoring: UtilityScoringConfig,
1718        }
1719
1720        let toml_str =
1721            "[utility_scoring]\nenabled = true\nhigh_gain_tools = [\"github_create_issue\"]\n";
1722        let w: Wrapper = toml::from_str(toml_str).unwrap();
1723        assert_eq!(
1724            w.utility_scoring.high_gain_tools,
1725            vec!["github_create_issue"]
1726        );
1727
1728        let json = serde_json::to_string(&w.utility_scoring).unwrap();
1729        let back: UtilityScoringConfig = serde_json::from_str(&json).unwrap();
1730        assert_eq!(back.high_gain_tools, vec!["github_create_issue"]);
1731    }
1732
1733    #[test]
1734    fn adversarial_policy_provider_default_is_empty() {
1735        let config = AdversarialPolicyConfig::default();
1736        assert!(config.policy_provider.is_empty());
1737    }
1738
1739    #[test]
1740    fn adversarial_policy_provider_serde_roundtrip() {
1741        #[derive(serde::Deserialize)]
1742        struct Wrapper {
1743            adversarial_policy: AdversarialPolicyConfig,
1744        }
1745        let toml_str = r#"
1746            [adversarial_policy]
1747            enabled = true
1748            policy_provider = "fast-llm"
1749        "#;
1750        let w: Wrapper = toml::from_str(toml_str).unwrap();
1751        assert_eq!(w.adversarial_policy.policy_provider.as_str(), "fast-llm");
1752
1753        let json = serde_json::to_string(&w.adversarial_policy).unwrap();
1754        let back: AdversarialPolicyConfig = serde_json::from_str(&json).unwrap();
1755        assert_eq!(back.policy_provider.as_str(), "fast-llm");
1756    }
1757
1758    #[test]
1759    fn adversarial_policy_provider_empty_when_omitted() {
1760        #[derive(serde::Deserialize)]
1761        struct Wrapper {
1762            adversarial_policy: AdversarialPolicyConfig,
1763        }
1764        let toml_str = r"
1765            [adversarial_policy]
1766            enabled = true
1767        ";
1768        let w: Wrapper = toml::from_str(toml_str).unwrap();
1769        assert!(
1770            w.adversarial_policy.policy_provider.is_empty(),
1771            "omitted policy_provider must default to empty (→ primary provider used)"
1772        );
1773    }
1774
1775    #[test]
1776    fn adversarial_policy_timeout_ms_defaults_to_none() {
1777        // None means "auto-scale by resolved policy_provider kind" — see #5870.
1778        let config = AdversarialPolicyConfig::default();
1779        assert_eq!(config.timeout_ms, None);
1780    }
1781
1782    #[test]
1783    fn adversarial_policy_timeout_ms_explicit_override_roundtrips() {
1784        #[derive(serde::Deserialize)]
1785        struct Wrapper {
1786            adversarial_policy: AdversarialPolicyConfig,
1787        }
1788        let toml_str = r"
1789            [adversarial_policy]
1790            enabled = true
1791            timeout_ms = 90000
1792        ";
1793        let w: Wrapper = toml::from_str(toml_str).unwrap();
1794        assert_eq!(w.adversarial_policy.timeout_ms, Some(90_000));
1795
1796        let json = serde_json::to_string(&w.adversarial_policy).unwrap();
1797        let back: AdversarialPolicyConfig = serde_json::from_str(&json).unwrap();
1798        assert_eq!(back.timeout_ms, Some(90_000));
1799    }
1800
1801    #[test]
1802    fn adversarial_timeout_for_provider_kind_scales_local_vs_cloud() {
1803        assert_eq!(adversarial_timeout_for_provider_kind("ollama"), 45_000);
1804        assert_eq!(adversarial_timeout_for_provider_kind("candle"), 45_000);
1805        assert_eq!(adversarial_timeout_for_provider_kind("local"), 45_000);
1806        assert_eq!(adversarial_timeout_for_provider_kind("cloud"), 3_000);
1807        assert_eq!(adversarial_timeout_for_provider_kind("unknown"), 3_000);
1808    }
1809}