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}