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    /// Advertise and permit every Winx tool.
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
25impl ToolProfile {
26    const fn mask(self) -> u16 {
27        match self {
28            Self::Full => {
29                ToolKind::Initialize.bit()
30                    | ToolKind::BashCommand.bit()
31                    | ToolKind::ReadFiles.bit()
32                    | ToolKind::FileWriteOrEdit.bit()
33                    | ToolKind::MultiFileEdit.bit()
34                    | ToolKind::VerifyEdit.bit()
35                    | ToolKind::UndoEdit.bit()
36                    | ToolKind::ContextSave.bit()
37                    | ToolKind::ReadImage.bit()
38                    | ToolKind::CodeMap.bit()
39                    | ToolKind::ApplyPatch.bit()
40            }
41            Self::Coding => {
42                ToolKind::Initialize.bit()
43                    | ToolKind::BashCommand.bit()
44                    | ToolKind::ReadFiles.bit()
45                    | ToolKind::FileWriteOrEdit.bit()
46                    | ToolKind::MultiFileEdit.bit()
47                    | ToolKind::VerifyEdit.bit()
48                    | ToolKind::UndoEdit.bit()
49                    | ToolKind::CodeMap.bit()
50                    | ToolKind::ApplyPatch.bit()
51            }
52            Self::ReadOnly => {
53                ToolKind::Initialize.bit()
54                    | ToolKind::ReadFiles.bit()
55                    | ToolKind::ReadImage.bit()
56                    | ToolKind::CodeMap.bit()
57            }
58            Self::Terminal => ToolKind::Initialize.bit() | ToolKind::BashCommand.bit(),
59        }
60    }
61}
62
63/// Fast, immutable allowlist used both for catalog filtering and call routing.
64#[derive(Clone, Copy, Debug, Eq, PartialEq)]
65pub struct ToolPolicy {
66    mask: u16,
67}
68
69impl Default for ToolPolicy {
70    fn default() -> Self {
71        Self::from_profile(ToolProfile::Full)
72    }
73}
74
75impl ToolPolicy {
76    /// Build a policy from one curated profile.
77    pub const fn from_profile(profile: ToolProfile) -> Self {
78        Self { mask: profile.mask() }
79    }
80
81    /// Build an exact allowlist. Names are case-sensitive and duplicates are harmless.
82    pub fn from_allowed_tools<I, S>(names: I) -> Result<Self>
83    where
84        I: IntoIterator<Item = S>,
85        S: AsRef<str>,
86    {
87        let mut mask = 0_u16;
88        let mut saw_name = false;
89        for name in names {
90            saw_name = true;
91            let name = name.as_ref();
92            let kind = ToolKind::parse(name).ok_or_else(|| {
93                WinxError::ConfigurationError(format!(
94                    "unknown MCP tool {name:?}; expected one of {}",
95                    ALL_TOOL_NAMES.join(", ")
96                ))
97            })?;
98            mask |= kind.bit();
99        }
100        if !saw_name {
101            return Err(WinxError::ConfigurationError(
102                "MCP tool allowlist cannot be empty".to_string(),
103            ));
104        }
105        if mask & ToolKind::VerifyEdit.bit() != 0 && mask & ToolKind::BashCommand.bit() == 0 {
106            return Err(WinxError::ConfigurationError(
107                "VerifyEdit requires BashCommand in the same MCP tool allowlist".to_string(),
108            ));
109        }
110        Ok(Self { mask })
111    }
112
113    /// Use an explicit allowlist when present, otherwise use the selected profile.
114    pub fn resolve(profile: ToolProfile, allowed_tools: Option<&[String]>) -> Result<Self> {
115        allowed_tools.map_or_else(
116            || Ok(Self::from_profile(profile)),
117            |names| Self::from_allowed_tools(names.iter()),
118        )
119    }
120
121    /// Whether a tool may be advertised and called.
122    pub fn allows(self, name: &str) -> bool {
123        ToolKind::parse(name).is_some_and(|kind| self.allows_kind(kind))
124    }
125
126    /// Typed policy check used after the wire name has been parsed once.
127    pub const fn allows_kind(self, kind: ToolKind) -> bool {
128        self.mask & kind.bit() != 0
129    }
130
131    /// Allowed names in stable catalog order.
132    pub fn names(self) -> impl Iterator<Item = &'static str> {
133        ToolKind::ALL.into_iter().filter(move |kind| self.allows_kind(*kind)).map(ToolKind::as_str)
134    }
135
136    /// Number of advertised tools.
137    pub fn len(self) -> usize {
138        self.mask.count_ones() as usize
139    }
140
141    /// Whether this policy advertises no tools.
142    pub fn is_empty(self) -> bool {
143        self.mask == 0
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    #![allow(clippy::expect_used)]
150
151    use super::{ToolPolicy, ToolProfile, ALL_TOOL_NAMES};
152
153    #[test]
154    fn profiles_have_stable_expected_catalogs() {
155        assert_eq!(ToolPolicy::default().names().collect::<Vec<_>>(), ALL_TOOL_NAMES);
156        assert_eq!(
157            ToolPolicy::from_profile(ToolProfile::Coding).names().collect::<Vec<_>>(),
158            vec![
159                "Initialize",
160                "BashCommand",
161                "ReadFiles",
162                "FileWriteOrEdit",
163                "MultiFileEdit",
164                "VerifyEdit",
165                "UndoEdit",
166                "CodeMap",
167                "ApplyPatch",
168            ]
169        );
170        assert_eq!(
171            ToolPolicy::from_profile(ToolProfile::ReadOnly).names().collect::<Vec<_>>(),
172            vec!["Initialize", "ReadFiles", "ReadImage", "CodeMap"]
173        );
174        assert_eq!(
175            ToolPolicy::from_profile(ToolProfile::Terminal).names().collect::<Vec<_>>(),
176            vec!["Initialize", "BashCommand"]
177        );
178    }
179
180    #[test]
181    fn explicit_allowlist_is_exact_deduplicated_and_validated() {
182        let policy = ToolPolicy::from_allowed_tools(["ReadFiles", "Initialize", "ReadFiles"])
183            .expect("valid policy");
184        assert_eq!(policy.names().collect::<Vec<_>>(), vec!["Initialize", "ReadFiles"]);
185        assert!(ToolPolicy::from_allowed_tools(["readfiles"]).is_err());
186        assert!(ToolPolicy::from_allowed_tools(Vec::<String>::new()).is_err());
187        assert!(ToolPolicy::from_allowed_tools(["VerifyEdit"]).is_err());
188        assert!(ToolPolicy::from_allowed_tools(["VerifyEdit", "BashCommand"]).is_ok());
189    }
190}