Skip to main content

winx_code_agent/
tool_policy.rs

1//! Per-principal MCP tool catalog policies.
2
3use serde::Deserialize;
4
5use crate::errors::{Result, WinxError};
6use crate::tool_registry::ToolKind;
7
8pub use crate::tool_registry::ALL_TOOL_NAMES;
9
10/// Curated catalog shapes for common MCP clients and workflows.
11#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, clap::ValueEnum)]
12#[serde(rename_all = "kebab-case")]
13pub enum ToolProfile {
14    /// Complete unified catalog; legacy edit aliases remain callable but hidden.
15    #[default]
16    Full,
17    /// Core repository exploration and editing without image or handoff helpers.
18    Coding,
19    /// Filesystem read/navigation tools only; shell and edit calls are rejected.
20    ReadOnly,
21    /// Durable terminal access with the smallest useful catalog.
22    Terminal,
23}
24
25/// Exact authority for normalized file mutations. Advertised tool names and
26/// compatibility-alias authority remain deliberately separate.
27#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
28pub struct EditPermissionSet {
29    bits: u16,
30}
31
32impl EditPermissionSet {
33    const SINGLE_REPLACE: u16 = 1 << 0;
34    const SINGLE_SEARCH_REPLACE: u16 = 1 << 1;
35    const SINGLE_LINE_PATCH: u16 = 1 << 2;
36    const SINGLE_UNDO: u16 = 1 << 3;
37    const BATCH_REPLACE: u16 = 1 << 4;
38    const BATCH_SEARCH_REPLACE: u16 = 1 << 5;
39    const BATCH_LINE_PATCH: u16 = 1 << 6;
40    const VERIFY: u16 = 1 << 7;
41
42    const ALL: Self = Self {
43        bits: Self::SINGLE_REPLACE
44            | Self::SINGLE_SEARCH_REPLACE
45            | Self::SINGLE_LINE_PATCH
46            | Self::SINGLE_UNDO
47            | Self::BATCH_REPLACE
48            | Self::BATCH_SEARCH_REPLACE
49            | Self::BATCH_LINE_PATCH,
50    };
51
52    pub const fn is_empty(self) -> bool {
53        self.bits == 0
54    }
55
56    pub const fn has_mutation_authority(self) -> bool {
57        self.bits & !Self::VERIFY != 0
58    }
59
60    pub const fn allows(self, mode: crate::tools::edit_files::EditMode, file_count: usize) -> bool {
61        let batch = file_count > 1;
62        let bit = match (mode, batch) {
63            (crate::tools::edit_files::EditMode::Replace, false) => Self::SINGLE_REPLACE,
64            (crate::tools::edit_files::EditMode::SearchReplace, false) => {
65                Self::SINGLE_SEARCH_REPLACE
66            }
67            (crate::tools::edit_files::EditMode::LinePatch, false) => Self::SINGLE_LINE_PATCH,
68            (crate::tools::edit_files::EditMode::Undo, false) => Self::SINGLE_UNDO,
69            (crate::tools::edit_files::EditMode::Replace, true) => Self::BATCH_REPLACE,
70            (crate::tools::edit_files::EditMode::SearchReplace, true) => Self::BATCH_SEARCH_REPLACE,
71            (crate::tools::edit_files::EditMode::LinePatch, true) => Self::BATCH_LINE_PATCH,
72            (crate::tools::edit_files::EditMode::Undo, true) => 0,
73        };
74        self.bits & bit != 0
75    }
76
77    pub const fn allows_verification(self) -> bool {
78        self.bits & Self::VERIFY != 0
79    }
80
81    pub const fn for_legacy_tool(tool: ToolKind) -> Self {
82        let bits = match tool {
83            ToolKind::FileWriteOrEdit => Self::SINGLE_REPLACE | Self::SINGLE_SEARCH_REPLACE,
84            ToolKind::MultiFileEdit => Self::BATCH_REPLACE | Self::BATCH_SEARCH_REPLACE,
85            ToolKind::ApplyPatch => Self::SINGLE_LINE_PATCH,
86            ToolKind::UndoEdit => Self::SINGLE_UNDO,
87            _ => 0,
88        };
89        Self { bits }
90    }
91
92    pub(crate) const fn all_mutations() -> Self {
93        Self::ALL
94    }
95
96    const fn union(self, other: Self) -> Self {
97        Self { bits: self.bits | other.bits }
98    }
99}
100
101impl ToolProfile {
102    const fn mask(self) -> u16 {
103        match self {
104            Self::Full => {
105                ToolKind::Initialize.bit()
106                    | ToolKind::BashCommand.bit()
107                    | ToolKind::ReadFiles.bit()
108                    | ToolKind::ContextSave.bit()
109                    | ToolKind::ReadImage.bit()
110                    | ToolKind::CodeMap.bit()
111                    | ToolKind::EditFiles.bit()
112            }
113            Self::Coding => {
114                ToolKind::Initialize.bit()
115                    | ToolKind::BashCommand.bit()
116                    | ToolKind::ReadFiles.bit()
117                    | ToolKind::CodeMap.bit()
118                    | ToolKind::EditFiles.bit()
119            }
120            Self::ReadOnly => {
121                ToolKind::Initialize.bit()
122                    | ToolKind::ReadFiles.bit()
123                    | ToolKind::ReadImage.bit()
124                    | ToolKind::CodeMap.bit()
125            }
126            Self::Terminal => ToolKind::Initialize.bit() | ToolKind::BashCommand.bit(),
127        }
128    }
129}
130
131/// Fast, immutable allowlist used both for catalog filtering and call routing.
132#[derive(Clone, Copy, Debug, Eq, PartialEq)]
133pub struct ToolPolicy {
134    mask: u16,
135}
136
137impl Default for ToolPolicy {
138    fn default() -> Self {
139        Self::from_profile(ToolProfile::Full)
140    }
141}
142
143impl ToolPolicy {
144    /// Build a policy from one curated profile.
145    pub const fn from_profile(profile: ToolProfile) -> Self {
146        Self { mask: profile.mask() }
147    }
148
149    /// Build an exact allowlist. Names are case-sensitive and duplicates are harmless.
150    pub fn from_allowed_tools<I, S>(names: I) -> Result<Self>
151    where
152        I: IntoIterator<Item = S>,
153        S: AsRef<str>,
154    {
155        let mut mask = 0_u16;
156        let mut saw_name = false;
157        for name in names {
158            saw_name = true;
159            let name = name.as_ref();
160            let kind = ToolKind::parse(name).ok_or_else(|| {
161                WinxError::ConfigurationError(format!(
162                    "unknown MCP tool {name:?}; expected one of {}",
163                    ALL_TOOL_NAMES.join(", ")
164                ))
165            })?;
166            mask |= kind.bit();
167        }
168        if !saw_name {
169            return Err(WinxError::ConfigurationError(
170                "MCP tool allowlist cannot be empty".to_string(),
171            ));
172        }
173        if mask & ToolKind::VerifyEdit.bit() != 0 && mask & ToolKind::BashCommand.bit() == 0 {
174            return Err(WinxError::ConfigurationError(
175                "VerifyEdit requires BashCommand in the same MCP tool allowlist".to_string(),
176            ));
177        }
178        Ok(Self { mask })
179    }
180
181    /// Use an explicit allowlist when present, otherwise use the selected profile.
182    pub fn resolve(profile: ToolProfile, allowed_tools: Option<&[String]>) -> Result<Self> {
183        allowed_tools.map_or_else(
184            || Ok(Self::from_profile(profile)),
185            |names| Self::from_allowed_tools(names.iter()),
186        )
187    }
188
189    /// Whether a tool is advertised in `tools/list`.
190    pub fn allows(self, name: &str) -> bool {
191        ToolKind::parse(name).is_some_and(|kind| self.allows_kind(kind))
192    }
193
194    /// Typed policy check used after the wire name has been parsed once.
195    pub const fn allows_kind(self, kind: ToolKind) -> bool {
196        self.mask & kind.bit() != 0
197    }
198
199    /// Whether a registered name may execute. Unified profiles keep the five
200    /// former edit tools as hidden aliases so cached clients continue safely;
201    /// exact legacy allowlists conversely retain scoped access to `EditFiles`.
202    pub fn allows_call(self, name: &str) -> bool {
203        ToolKind::parse(name).is_some_and(|kind| self.allows_call_kind(kind))
204    }
205
206    pub const fn allows_call_kind(self, kind: ToolKind) -> bool {
207        if self.allows_kind(kind) {
208            return true;
209        }
210        let permissions = self.edit_permissions();
211        match kind {
212            ToolKind::EditFiles => permissions.has_mutation_authority(),
213            ToolKind::FileWriteOrEdit => {
214                permissions.allows(crate::tools::edit_files::EditMode::Replace, 1)
215                    && permissions.allows(crate::tools::edit_files::EditMode::SearchReplace, 1)
216            }
217            ToolKind::MultiFileEdit => {
218                permissions.allows(crate::tools::edit_files::EditMode::Replace, 2)
219                    && permissions.allows(crate::tools::edit_files::EditMode::SearchReplace, 2)
220            }
221            ToolKind::ApplyPatch => {
222                permissions.allows(crate::tools::edit_files::EditMode::LinePatch, 1)
223            }
224            ToolKind::UndoEdit => permissions.allows(crate::tools::edit_files::EditMode::Undo, 1),
225            ToolKind::VerifyEdit => {
226                self.allows_kind(ToolKind::EditFiles) && self.allows_kind(ToolKind::BashCommand)
227            }
228            _ => false,
229        }
230    }
231
232    pub const fn edit_permissions(self) -> EditPermissionSet {
233        let mut permissions = EditPermissionSet { bits: 0 };
234        if self.allows_kind(ToolKind::EditFiles) {
235            permissions = permissions.union(EditPermissionSet::ALL);
236        }
237        if self.allows_kind(ToolKind::FileWriteOrEdit) {
238            permissions =
239                permissions.union(EditPermissionSet::for_legacy_tool(ToolKind::FileWriteOrEdit));
240        }
241        if self.allows_kind(ToolKind::MultiFileEdit) {
242            permissions =
243                permissions.union(EditPermissionSet::for_legacy_tool(ToolKind::MultiFileEdit));
244        }
245        if self.allows_kind(ToolKind::ApplyPatch) {
246            permissions =
247                permissions.union(EditPermissionSet::for_legacy_tool(ToolKind::ApplyPatch));
248        }
249        if self.allows_kind(ToolKind::UndoEdit) {
250            permissions = permissions.union(EditPermissionSet::for_legacy_tool(ToolKind::UndoEdit));
251        }
252        if self.allows_kind(ToolKind::BashCommand) {
253            permissions = permissions.union(EditPermissionSet { bits: EditPermissionSet::VERIFY });
254        }
255        permissions
256    }
257
258    /// Allowed names in stable catalog order.
259    pub fn names(self) -> impl Iterator<Item = &'static str> {
260        ToolKind::ALL.into_iter().filter(move |kind| self.allows_kind(*kind)).map(ToolKind::as_str)
261    }
262
263    /// Number of advertised tools.
264    pub fn len(self) -> usize {
265        self.names().count()
266    }
267
268    /// Whether this policy advertises no tools.
269    pub fn is_empty(self) -> bool {
270        self.len() == 0
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    #![allow(clippy::expect_used)]
277
278    use super::{ToolPolicy, ToolProfile};
279    use crate::tool_registry::ToolKind;
280    use crate::tools::edit_files::EditMode;
281
282    #[test]
283    fn profiles_have_stable_expected_catalogs() {
284        assert_eq!(
285            ToolPolicy::default().names().collect::<Vec<_>>(),
286            vec![
287                "Initialize",
288                "BashCommand",
289                "ReadFiles",
290                "ContextSave",
291                "ReadImage",
292                "CodeMap",
293                "EditFiles",
294            ]
295        );
296        assert_eq!(
297            ToolPolicy::from_profile(ToolProfile::Coding).names().collect::<Vec<_>>(),
298            vec!["Initialize", "BashCommand", "ReadFiles", "CodeMap", "EditFiles"]
299        );
300        assert_eq!(
301            ToolPolicy::from_profile(ToolProfile::ReadOnly).names().collect::<Vec<_>>(),
302            vec!["Initialize", "ReadFiles", "ReadImage", "CodeMap"]
303        );
304        assert_eq!(
305            ToolPolicy::from_profile(ToolProfile::Terminal).names().collect::<Vec<_>>(),
306            vec!["Initialize", "BashCommand"]
307        );
308    }
309
310    #[test]
311    fn explicit_allowlist_is_exact_deduplicated_and_validated() {
312        let policy = ToolPolicy::from_allowed_tools(["ReadFiles", "Initialize", "ReadFiles"])
313            .expect("valid policy");
314        assert_eq!(policy.names().collect::<Vec<_>>(), vec!["Initialize", "ReadFiles"]);
315        assert!(ToolPolicy::from_allowed_tools(["readfiles"]).is_err());
316        assert!(ToolPolicy::from_allowed_tools(Vec::<String>::new()).is_err());
317        assert!(ToolPolicy::from_allowed_tools(["VerifyEdit"]).is_err());
318        assert!(ToolPolicy::from_allowed_tools(["VerifyEdit", "BashCommand"]).is_ok());
319    }
320
321    #[test]
322    fn edit_permissions_preserve_legacy_cardinality_and_require_bash_for_verification() {
323        let single = ToolPolicy::from_allowed_tools(["FileWriteOrEdit"]).expect("valid policy");
324        let permissions = single.edit_permissions();
325        assert!(permissions.allows(EditMode::Replace, 1));
326        assert!(permissions.allows(EditMode::SearchReplace, 1));
327        assert!(!permissions.allows(EditMode::Replace, 2));
328        assert!(!permissions.allows(EditMode::LinePatch, 1));
329        assert!(!permissions.allows_verification());
330
331        let verified = ToolPolicy::from_allowed_tools(["FileWriteOrEdit", "BashCommand"])
332            .expect("valid policy")
333            .edit_permissions();
334        assert!(verified.allows_verification());
335
336        let unified =
337            ToolPolicy::from_allowed_tools(["EditFiles"]).expect("valid unified edit policy");
338        assert_eq!(unified.names().collect::<Vec<_>>(), vec!["EditFiles"]);
339        assert!(!unified.is_empty());
340        assert!(unified.edit_permissions().allows(EditMode::LinePatch, 2));
341        assert!(!unified.edit_permissions().allows_verification());
342        assert!(unified.allows_call_kind(ToolKind::FileWriteOrEdit));
343        assert!(unified.allows_call_kind(ToolKind::MultiFileEdit));
344        assert!(unified.allows_call_kind(ToolKind::ApplyPatch));
345        assert!(unified.allows_call_kind(ToolKind::UndoEdit));
346        assert!(!unified.allows_call_kind(ToolKind::VerifyEdit));
347
348        let unified_with_bash =
349            ToolPolicy::from_allowed_tools(["EditFiles", "BashCommand"]).expect("valid policy");
350        assert!(unified_with_bash.allows_call_kind(ToolKind::VerifyEdit));
351
352        for (name, allowed_mode, count) in [
353            ("FileWriteOrEdit", EditMode::Replace, 1),
354            ("ApplyPatch", EditMode::LinePatch, 1),
355            ("MultiFileEdit", EditMode::SearchReplace, 2),
356        ] {
357            let policy = ToolPolicy::from_allowed_tools([name]).expect("valid legacy policy");
358            assert!(policy.allows_call_kind(ToolKind::EditFiles));
359            assert!(policy.edit_permissions().allows(allowed_mode, count));
360            assert!(!policy.edit_permissions().allows_verification());
361        }
362
363        let bash_only = ToolPolicy::from_allowed_tools(["BashCommand"]).expect("valid policy");
364        assert!(!bash_only.allows_call_kind(ToolKind::EditFiles));
365    }
366}