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 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#[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 pub const fn from_profile(profile: ToolProfile) -> Self {
159 Self { mask: profile.mask() }
160 }
161
162 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 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 pub fn allows(self, name: &str) -> bool {
204 ToolKind::parse(name).is_some_and(|kind| self.allows_kind(kind))
205 }
206
207 pub const fn allows_kind(self, kind: ToolKind) -> bool {
209 self.mask & kind.bit() != 0
210 }
211
212 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 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 pub fn len(self) -> usize {
278 self.names().count()
279 }
280
281 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}