Skip to main content

zeph_config/
plugins.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Plugin subsystem configuration (`[plugins]`).
5
6use serde::{Deserialize, Serialize};
7
8fn default_reputation_enabled() -> bool {
9    true
10}
11
12fn default_reputation_similarity_threshold() -> f32 {
13    0.65
14}
15
16fn default_reputation_min_name_len() -> usize {
17    3
18}
19
20/// Top-level plugin subsystem configuration (`[plugins]`).
21///
22/// Currently holds only the install-time reputation (typosquat) check (spec-043, #5864); more
23/// plugin-wide settings may be added here later.
24#[derive(Debug, Clone, Default, Deserialize, Serialize)]
25pub struct PluginsConfig {
26    /// Install-time name-similarity typosquat check (`[plugins.reputation]`).
27    #[serde(default)]
28    pub reputation: ReputationConfig,
29}
30
31/// Install-time plugin/skill name-similarity ("typosquat") advisory check (spec-043, #5864).
32///
33/// Compares an incoming plugin's declared name and skill names against
34/// `zeph_skills::bundled::bundled_skill_names()` plus managed and other installed plugins'
35/// skill names, using a Levenshtein-based similarity ratio computed entirely locally — zero
36/// network calls (NFR-001). Advisory by default (`enforcement = "warn"`, FR-006/SC-004).
37///
38/// # Examples
39///
40/// ```toml
41/// [plugins.reputation]
42/// enabled = true
43/// similarity_threshold = 0.65
44/// min_name_len = 3
45/// enforcement = "warn"
46/// ```
47#[derive(Debug, Clone, Deserialize, Serialize)]
48pub struct ReputationConfig {
49    /// Enable the check at install time. Default: `true` (advisory, zero-network, mirrors the
50    /// on-by-default posture of the existing skill-body injection scan).
51    #[serde(default = "default_reputation_enabled")]
52    pub enabled: bool,
53    /// Similarity ratio in `[0, 1]` at or above which a near-match warns. Higher = stricter
54    /// (requires a closer match) = fewer warnings. Default `0.65` — the loosest value that
55    /// still catches the motivating `github-pr`/`git-pr` example (similarity 0.667) with a
56    /// margin, while producing zero false positives among Zeph's own bundled skill names.
57    #[serde(default = "default_reputation_similarity_threshold")]
58    pub similarity_threshold: f32,
59    /// Skip comparisons where the shorter of the two compared names has fewer than this many
60    /// characters. Default `3` — covers the bundled `git` skill (spec-043 M1); 1-2 character
61    /// names are always skipped as noise regardless of this setting.
62    #[serde(default = "default_reputation_min_name_len")]
63    pub min_name_len: usize,
64    /// `"warn"` (default, advisory-only — install/update proceeds) or `"block"` (opt-in hard
65    /// gate: the install/update is refused before any file is written or swapped).
66    /// `zeph plugin add --strict-reputation` overrides this to `"block"` for a single
67    /// invocation without changing the persisted config.
68    #[serde(default)]
69    pub enforcement: ReputationEnforcement,
70}
71
72impl Default for ReputationConfig {
73    fn default() -> Self {
74        Self {
75            enabled: default_reputation_enabled(),
76            similarity_threshold: default_reputation_similarity_threshold(),
77            min_name_len: default_reputation_min_name_len(),
78            enforcement: ReputationEnforcement::default(),
79        }
80    }
81}
82
83/// Enforcement posture for [`ReputationConfig`].
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
85#[serde(rename_all = "snake_case")]
86#[non_exhaustive]
87pub enum ReputationEnforcement {
88    /// Surface a warning; the install/update proceeds (FR-006, SC-004 default posture).
89    #[default]
90    Warn,
91    /// Refuse the install/update before any file is written or swapped (opt-in, FR-006).
92    Block,
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn plugins_config_default_matches_documented_values() {
101        let cfg = PluginsConfig::default();
102        assert!(cfg.reputation.enabled);
103        assert!((cfg.reputation.similarity_threshold - 0.65).abs() < f32::EPSILON);
104        assert_eq!(cfg.reputation.min_name_len, 3);
105        assert_eq!(cfg.reputation.enforcement, ReputationEnforcement::Warn);
106    }
107
108    #[test]
109    fn reputation_config_deserializes_from_partial_toml() {
110        let toml_str = "enabled = false\n";
111        let cfg: ReputationConfig = toml::from_str(toml_str).unwrap();
112        assert!(!cfg.enabled);
113        // Fields absent from the input fall back to defaults, not zero values.
114        assert!((cfg.similarity_threshold - 0.65).abs() < f32::EPSILON);
115        assert_eq!(cfg.min_name_len, 3);
116    }
117
118    #[test]
119    fn reputation_enforcement_serializes_snake_case() {
120        let cfg = ReputationConfig {
121            enforcement: ReputationEnforcement::Block,
122            ..ReputationConfig::default()
123        };
124        let toml_str = toml::to_string(&cfg).unwrap();
125        assert!(
126            toml_str.contains("enforcement = \"block\""),
127            "got: {toml_str}"
128        );
129    }
130}