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