winx_code_agent/
tool_policy.rs1use serde::Deserialize;
4
5use crate::errors::{Result, WinxError};
6use crate::tool_registry::ToolKind;
7
8pub use crate::tool_registry::ALL_TOOL_NAMES;
9
10#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, clap::ValueEnum)]
12#[serde(rename_all = "kebab-case")]
13pub enum ToolProfile {
14 #[default]
16 Full,
17 Coding,
19 ReadOnly,
21 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#[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 pub const fn from_profile(profile: ToolProfile) -> Self {
78 Self { mask: profile.mask() }
79 }
80
81 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 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 pub fn allows(self, name: &str) -> bool {
123 ToolKind::parse(name).is_some_and(|kind| self.allows_kind(kind))
124 }
125
126 pub const fn allows_kind(self, kind: ToolKind) -> bool {
128 self.mask & kind.bit() != 0
129 }
130
131 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 pub fn len(self) -> usize {
138 self.mask.count_ones() as usize
139 }
140
141 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}