use serde::Deserialize;
use crate::errors::{Result, WinxError};
use crate::tool_registry::ToolKind;
pub use crate::tool_registry::ALL_TOOL_NAMES;
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub enum ToolProfile {
#[default]
Full,
Coding,
ReadOnly,
Terminal,
}
impl ToolProfile {
const fn mask(self) -> u16 {
match self {
Self::Full => {
ToolKind::Initialize.bit()
| ToolKind::BashCommand.bit()
| ToolKind::ReadFiles.bit()
| ToolKind::FileWriteOrEdit.bit()
| ToolKind::MultiFileEdit.bit()
| ToolKind::VerifyEdit.bit()
| ToolKind::UndoEdit.bit()
| ToolKind::ContextSave.bit()
| ToolKind::ReadImage.bit()
| ToolKind::CodeMap.bit()
| ToolKind::ApplyPatch.bit()
}
Self::Coding => {
ToolKind::Initialize.bit()
| ToolKind::BashCommand.bit()
| ToolKind::ReadFiles.bit()
| ToolKind::FileWriteOrEdit.bit()
| ToolKind::MultiFileEdit.bit()
| ToolKind::VerifyEdit.bit()
| ToolKind::UndoEdit.bit()
| ToolKind::CodeMap.bit()
| ToolKind::ApplyPatch.bit()
}
Self::ReadOnly => {
ToolKind::Initialize.bit()
| ToolKind::ReadFiles.bit()
| ToolKind::ReadImage.bit()
| ToolKind::CodeMap.bit()
}
Self::Terminal => ToolKind::Initialize.bit() | ToolKind::BashCommand.bit(),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ToolPolicy {
mask: u16,
}
impl Default for ToolPolicy {
fn default() -> Self {
Self::from_profile(ToolProfile::Full)
}
}
impl ToolPolicy {
pub const fn from_profile(profile: ToolProfile) -> Self {
Self { mask: profile.mask() }
}
pub fn from_allowed_tools<I, S>(names: I) -> Result<Self>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut mask = 0_u16;
let mut saw_name = false;
for name in names {
saw_name = true;
let name = name.as_ref();
let kind = ToolKind::parse(name).ok_or_else(|| {
WinxError::ConfigurationError(format!(
"unknown MCP tool {name:?}; expected one of {}",
ALL_TOOL_NAMES.join(", ")
))
})?;
mask |= kind.bit();
}
if !saw_name {
return Err(WinxError::ConfigurationError(
"MCP tool allowlist cannot be empty".to_string(),
));
}
if mask & ToolKind::VerifyEdit.bit() != 0 && mask & ToolKind::BashCommand.bit() == 0 {
return Err(WinxError::ConfigurationError(
"VerifyEdit requires BashCommand in the same MCP tool allowlist".to_string(),
));
}
Ok(Self { mask })
}
pub fn resolve(profile: ToolProfile, allowed_tools: Option<&[String]>) -> Result<Self> {
allowed_tools.map_or_else(
|| Ok(Self::from_profile(profile)),
|names| Self::from_allowed_tools(names.iter()),
)
}
pub fn allows(self, name: &str) -> bool {
ToolKind::parse(name).is_some_and(|kind| self.allows_kind(kind))
}
pub const fn allows_kind(self, kind: ToolKind) -> bool {
self.mask & kind.bit() != 0
}
pub fn names(self) -> impl Iterator<Item = &'static str> {
ToolKind::ALL.into_iter().filter(move |kind| self.allows_kind(*kind)).map(ToolKind::as_str)
}
pub fn len(self) -> usize {
self.mask.count_ones() as usize
}
pub fn is_empty(self) -> bool {
self.mask == 0
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::{ToolPolicy, ToolProfile, ALL_TOOL_NAMES};
#[test]
fn profiles_have_stable_expected_catalogs() {
assert_eq!(ToolPolicy::default().names().collect::<Vec<_>>(), ALL_TOOL_NAMES);
assert_eq!(
ToolPolicy::from_profile(ToolProfile::Coding).names().collect::<Vec<_>>(),
vec![
"Initialize",
"BashCommand",
"ReadFiles",
"FileWriteOrEdit",
"MultiFileEdit",
"VerifyEdit",
"UndoEdit",
"CodeMap",
"ApplyPatch",
]
);
assert_eq!(
ToolPolicy::from_profile(ToolProfile::ReadOnly).names().collect::<Vec<_>>(),
vec!["Initialize", "ReadFiles", "ReadImage", "CodeMap"]
);
assert_eq!(
ToolPolicy::from_profile(ToolProfile::Terminal).names().collect::<Vec<_>>(),
vec!["Initialize", "BashCommand"]
);
}
#[test]
fn explicit_allowlist_is_exact_deduplicated_and_validated() {
let policy = ToolPolicy::from_allowed_tools(["ReadFiles", "Initialize", "ReadFiles"])
.expect("valid policy");
assert_eq!(policy.names().collect::<Vec<_>>(), vec!["Initialize", "ReadFiles"]);
assert!(ToolPolicy::from_allowed_tools(["readfiles"]).is_err());
assert!(ToolPolicy::from_allowed_tools(Vec::<String>::new()).is_err());
assert!(ToolPolicy::from_allowed_tools(["VerifyEdit"]).is_err());
assert!(ToolPolicy::from_allowed_tools(["VerifyEdit", "BashCommand"]).is_ok());
}
}