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,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct EditPermissionSet {
bits: u16,
}
impl EditPermissionSet {
const SINGLE_REPLACE: u16 = 1 << 0;
const SINGLE_SEARCH_REPLACE: u16 = 1 << 1;
const SINGLE_LINE_PATCH: u16 = 1 << 2;
const SINGLE_UNDO: u16 = 1 << 3;
const BATCH_REPLACE: u16 = 1 << 4;
const BATCH_SEARCH_REPLACE: u16 = 1 << 5;
const BATCH_LINE_PATCH: u16 = 1 << 6;
const VERIFY: u16 = 1 << 7;
const ALL: Self = Self {
bits: Self::SINGLE_REPLACE
| Self::SINGLE_SEARCH_REPLACE
| Self::SINGLE_LINE_PATCH
| Self::SINGLE_UNDO
| Self::BATCH_REPLACE
| Self::BATCH_SEARCH_REPLACE
| Self::BATCH_LINE_PATCH,
};
pub const fn is_empty(self) -> bool {
self.bits == 0
}
pub const fn has_mutation_authority(self) -> bool {
self.bits & !Self::VERIFY != 0
}
pub const fn allows(self, mode: crate::tools::edit_files::EditMode, file_count: usize) -> bool {
let batch = file_count > 1;
let bit = match (mode, batch) {
(crate::tools::edit_files::EditMode::Replace, false) => Self::SINGLE_REPLACE,
(crate::tools::edit_files::EditMode::SearchReplace, false) => {
Self::SINGLE_SEARCH_REPLACE
}
(crate::tools::edit_files::EditMode::LinePatch, false) => Self::SINGLE_LINE_PATCH,
(crate::tools::edit_files::EditMode::Undo, false) => Self::SINGLE_UNDO,
(crate::tools::edit_files::EditMode::Replace, true) => Self::BATCH_REPLACE,
(crate::tools::edit_files::EditMode::SearchReplace, true) => Self::BATCH_SEARCH_REPLACE,
(crate::tools::edit_files::EditMode::LinePatch, true) => Self::BATCH_LINE_PATCH,
(crate::tools::edit_files::EditMode::Undo, true) => 0,
};
self.bits & bit != 0
}
pub const fn allows_verification(self) -> bool {
self.bits & Self::VERIFY != 0
}
pub const fn for_legacy_tool(tool: ToolKind) -> Self {
let bits = match tool {
ToolKind::FileWriteOrEdit => Self::SINGLE_REPLACE | Self::SINGLE_SEARCH_REPLACE,
ToolKind::MultiFileEdit => Self::BATCH_REPLACE | Self::BATCH_SEARCH_REPLACE,
ToolKind::ApplyPatch => Self::SINGLE_LINE_PATCH,
ToolKind::UndoEdit => Self::SINGLE_UNDO,
_ => 0,
};
Self { bits }
}
pub(crate) const fn all_mutations() -> Self {
Self::ALL
}
const fn union(self, other: Self) -> Self {
Self { bits: self.bits | other.bits }
}
}
impl ToolProfile {
const fn mask(self) -> u16 {
match self {
Self::Full => {
ToolKind::Initialize.bit()
| ToolKind::BashCommand.bit()
| ToolKind::ReadFiles.bit()
| ToolKind::ContextSave.bit()
| ToolKind::ReadImage.bit()
| ToolKind::CodeMap.bit()
| ToolKind::EditFiles.bit()
}
Self::Coding => {
ToolKind::Initialize.bit()
| ToolKind::BashCommand.bit()
| ToolKind::ReadFiles.bit()
| ToolKind::CodeMap.bit()
| ToolKind::EditFiles.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 allows_call(self, name: &str) -> bool {
ToolKind::parse(name).is_some_and(|kind| self.allows_call_kind(kind))
}
pub const fn allows_call_kind(self, kind: ToolKind) -> bool {
if self.allows_kind(kind) {
return true;
}
let permissions = self.edit_permissions();
match kind {
ToolKind::EditFiles => permissions.has_mutation_authority(),
ToolKind::FileWriteOrEdit => {
permissions.allows(crate::tools::edit_files::EditMode::Replace, 1)
&& permissions.allows(crate::tools::edit_files::EditMode::SearchReplace, 1)
}
ToolKind::MultiFileEdit => {
permissions.allows(crate::tools::edit_files::EditMode::Replace, 2)
&& permissions.allows(crate::tools::edit_files::EditMode::SearchReplace, 2)
}
ToolKind::ApplyPatch => {
permissions.allows(crate::tools::edit_files::EditMode::LinePatch, 1)
}
ToolKind::UndoEdit => permissions.allows(crate::tools::edit_files::EditMode::Undo, 1),
ToolKind::VerifyEdit => {
self.allows_kind(ToolKind::EditFiles) && self.allows_kind(ToolKind::BashCommand)
}
_ => false,
}
}
pub const fn edit_permissions(self) -> EditPermissionSet {
let mut permissions = EditPermissionSet { bits: 0 };
if self.allows_kind(ToolKind::EditFiles) {
permissions = permissions.union(EditPermissionSet::ALL);
}
if self.allows_kind(ToolKind::FileWriteOrEdit) {
permissions =
permissions.union(EditPermissionSet::for_legacy_tool(ToolKind::FileWriteOrEdit));
}
if self.allows_kind(ToolKind::MultiFileEdit) {
permissions =
permissions.union(EditPermissionSet::for_legacy_tool(ToolKind::MultiFileEdit));
}
if self.allows_kind(ToolKind::ApplyPatch) {
permissions =
permissions.union(EditPermissionSet::for_legacy_tool(ToolKind::ApplyPatch));
}
if self.allows_kind(ToolKind::UndoEdit) {
permissions = permissions.union(EditPermissionSet::for_legacy_tool(ToolKind::UndoEdit));
}
if self.allows_kind(ToolKind::BashCommand) {
permissions = permissions.union(EditPermissionSet { bits: EditPermissionSet::VERIFY });
}
permissions
}
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.names().count()
}
pub fn is_empty(self) -> bool {
self.len() == 0
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::{ToolPolicy, ToolProfile};
use crate::tool_registry::ToolKind;
use crate::tools::edit_files::EditMode;
#[test]
fn profiles_have_stable_expected_catalogs() {
assert_eq!(
ToolPolicy::default().names().collect::<Vec<_>>(),
vec![
"Initialize",
"BashCommand",
"ReadFiles",
"ContextSave",
"ReadImage",
"CodeMap",
"EditFiles",
]
);
assert_eq!(
ToolPolicy::from_profile(ToolProfile::Coding).names().collect::<Vec<_>>(),
vec!["Initialize", "BashCommand", "ReadFiles", "CodeMap", "EditFiles"]
);
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());
}
#[test]
fn edit_permissions_preserve_legacy_cardinality_and_require_bash_for_verification() {
let single = ToolPolicy::from_allowed_tools(["FileWriteOrEdit"]).expect("valid policy");
let permissions = single.edit_permissions();
assert!(permissions.allows(EditMode::Replace, 1));
assert!(permissions.allows(EditMode::SearchReplace, 1));
assert!(!permissions.allows(EditMode::Replace, 2));
assert!(!permissions.allows(EditMode::LinePatch, 1));
assert!(!permissions.allows_verification());
let verified = ToolPolicy::from_allowed_tools(["FileWriteOrEdit", "BashCommand"])
.expect("valid policy")
.edit_permissions();
assert!(verified.allows_verification());
let unified =
ToolPolicy::from_allowed_tools(["EditFiles"]).expect("valid unified edit policy");
assert_eq!(unified.names().collect::<Vec<_>>(), vec!["EditFiles"]);
assert!(!unified.is_empty());
assert!(unified.edit_permissions().allows(EditMode::LinePatch, 2));
assert!(!unified.edit_permissions().allows_verification());
assert!(unified.allows_call_kind(ToolKind::FileWriteOrEdit));
assert!(unified.allows_call_kind(ToolKind::MultiFileEdit));
assert!(unified.allows_call_kind(ToolKind::ApplyPatch));
assert!(unified.allows_call_kind(ToolKind::UndoEdit));
assert!(!unified.allows_call_kind(ToolKind::VerifyEdit));
let unified_with_bash =
ToolPolicy::from_allowed_tools(["EditFiles", "BashCommand"]).expect("valid policy");
assert!(unified_with_bash.allows_call_kind(ToolKind::VerifyEdit));
for (name, allowed_mode, count) in [
("FileWriteOrEdit", EditMode::Replace, 1),
("ApplyPatch", EditMode::LinePatch, 1),
("MultiFileEdit", EditMode::SearchReplace, 2),
] {
let policy = ToolPolicy::from_allowed_tools([name]).expect("valid legacy policy");
assert!(policy.allows_call_kind(ToolKind::EditFiles));
assert!(policy.edit_permissions().allows(allowed_mode, count));
assert!(!policy.edit_permissions().allows_verification());
}
let bash_only = ToolPolicy::from_allowed_tools(["BashCommand"]).expect("valid policy");
assert!(!bash_only.allows_call_kind(ToolKind::EditFiles));
}
}