1use 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
25#[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#[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 pub const fn from_profile(profile: ToolProfile) -> Self {
146 Self { mask: profile.mask() }
147 }
148
149 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 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 pub fn allows(self, name: &str) -> bool {
191 ToolKind::parse(name).is_some_and(|kind| self.allows_kind(kind))
192 }
193
194 pub const fn allows_kind(self, kind: ToolKind) -> bool {
196 self.mask & kind.bit() != 0
197 }
198
199 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 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 pub fn len(self) -> usize {
265 self.names().count()
266 }
267
268 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}