1use crate::frontmatter::Frontmatter;
4use crate::tool_names::{Capability, ParsedToolName, parse_mars_tool_name, render_scoped};
5use serde::ser::{Serialize, SerializeMap, Serializer};
6use serde_yaml::Value;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum ToolAction {
11 Allow,
12 Deny,
13 Ask,
14}
15
16impl ToolAction {
17 pub fn as_str(self) -> &'static str {
18 match self {
19 Self::Allow => "allow",
20 Self::Deny => "deny",
21 Self::Ask => "ask",
22 }
23 }
24
25 fn parse(raw: &str) -> Option<Self> {
26 match raw {
27 "allow" => Some(Self::Allow),
28 "deny" => Some(Self::Deny),
29 "ask" => Some(Self::Ask),
30 _ => None,
31 }
32 }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ToolRule {
42 pub capability: Capability,
43 pub pattern: Option<String>,
45 pub action: ToolAction,
46}
47
48impl ToolRule {
49 pub fn from_parsed(parsed: ParsedToolName, action: ToolAction) -> Self {
50 Self {
51 capability: parsed.capability,
52 pattern: parsed.pattern,
53 action,
54 }
55 }
56
57 pub fn is_wildcard(&self) -> bool {
58 matches!(self.capability, Capability::Any)
59 }
60
61 pub fn is_mcp(&self) -> bool {
62 matches!(self.capability, Capability::Mcp(_))
63 }
64
65 pub fn render(&self) -> String {
67 match &self.capability {
68 Capability::Mcp(mcp_ref) => mcp_ref.to_canonical(),
69 capability => render_scoped(capability.name(), self.pattern.as_deref()),
70 }
71 }
72
73 fn wire_pattern(&self) -> Option<String> {
75 match &self.capability {
76 Capability::Mcp(mcp_ref) => Some(mcp_ref.to_pattern()),
77 _ => self.pattern.clone(),
78 }
79 }
80}
81
82impl Serialize for ToolRule {
86 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
87 let mut map = serializer.serialize_map(None)?;
88 map.serialize_entry("capability", self.capability.name())?;
89 map.serialize_entry("pattern", &self.wire_pattern())?;
90 map.serialize_entry("action", self.action.as_str())?;
91 map.end()
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)]
97#[serde(transparent)]
98pub struct EffectiveToolPolicy {
99 pub rules: Vec<ToolRule>,
100}
101
102impl EffectiveToolPolicy {
103 pub fn with_action(&self, action: ToolAction) -> impl Iterator<Item = &ToolRule> {
104 self.rules.iter().filter(move |rule| rule.action == action)
105 }
106 pub fn describe(&self) -> Vec<String> {
107 self.rules
108 .iter()
109 .map(|rule| format!("{}:{}", rule.action.as_str(), rule.render()))
110 .collect()
111 }
112 pub fn rendered(&self, action: ToolAction) -> Vec<String> {
113 self.with_action(action)
114 .filter(|rule| !rule.is_wildcard())
115 .map(ToolRule::render)
116 .collect()
117 }
118 pub fn is_allowlist(&self) -> bool {
119 self.rules
120 .iter()
121 .any(|rule| rule.is_wildcard() && rule.action == ToolAction::Deny)
122 }
123 pub fn push_unique(&mut self, rule: ToolRule) {
124 if !self.rules.iter().any(|existing| {
125 existing.capability == rule.capability
126 && existing.pattern == rule.pattern
127 && existing.action == rule.action
128 }) {
129 self.rules.push(rule);
130 }
131 }
132}
133
134pub fn append_mcp_server_entries_to_tools(fm: &mut Frontmatter, servers: &[String]) -> bool {
136 let entries: Vec<String> = servers
137 .iter()
138 .map(|server| server.trim())
139 .filter(|server| !server.is_empty())
140 .map(|server| format!("mcp({server})"))
141 .collect();
142 if entries.is_empty() {
143 return false;
144 }
145
146 let Some(existing) = fm.get("tools").cloned() else {
147 let grants = entries
150 .into_iter()
151 .map(|entry| (Value::String(entry), Value::String("allow".into())))
152 .collect::<serde_yaml::Mapping>();
153 fm.insert("tools", Value::Mapping(grants));
154 return true;
155 };
156
157 match existing {
158 Value::Mapping(mut mapping) => {
159 let mut changed = false;
160 for entry in entries {
161 let key = Value::String(entry);
162 if !mapping.contains_key(&key) {
163 mapping.insert(key, Value::String("allow".into()));
164 changed = true;
165 }
166 }
167 if changed {
168 fm.insert("tools", Value::Mapping(mapping));
169 }
170 changed
171 }
172 other => {
173 let mut tools = tool_entries(&other);
174 let mut changed = false;
175 for entry in entries {
176 if !tools.iter().any(|existing| existing == &entry) {
177 tools.push(entry);
178 changed = true;
179 }
180 }
181 if changed {
182 fm.insert(
183 "tools",
184 Value::Sequence(tools.into_iter().map(Value::String).collect()),
185 );
186 }
187 changed
188 }
189 }
190}
191
192pub(crate) fn tool_entries(val: &Value) -> Vec<String> {
196 let Value::String(raw) = val else {
197 return yaml_str_list(val);
198 };
199 let mut entries = Vec::new();
200 let mut depth = 0usize;
201 let mut start = 0;
202 for (index, ch) in raw.char_indices() {
203 match ch {
204 '(' => depth += 1,
205 ')' => depth = depth.saturating_sub(1),
206 ',' | ' ' | '\t' | '\n' if depth == 0 => {
208 entries.push(&raw[start..index]);
209 start = index + 1;
210 }
211 _ => {}
212 }
213 }
214 entries.push(&raw[start..]);
215 entries
216 .into_iter()
217 .map(str::trim)
218 .filter(|entry| !entry.is_empty())
219 .map(str::to_owned)
220 .collect()
221}
222
223pub fn yaml_str_list(val: &Value) -> Vec<String> {
224 match val {
225 Value::Sequence(seq) => seq
226 .iter()
227 .filter_map(|v| v.as_str())
228 .map(str::to_owned)
229 .collect(),
230 Value::String(s) => vec![s.clone()],
231 _ => vec![],
232 }
233}
234
235type OnInvalid<'a> = &'a mut dyn FnMut(Vec<crate::diagnostic::PathSegment>, &str, &'static str);
236
237pub fn parse_tool_policy(tools: Option<&Value>, on_invalid: OnInvalid<'_>) -> EffectiveToolPolicy {
241 let mut policy = EffectiveToolPolicy::default();
242 if let Some(value) = tools {
243 for rule in parse_tools_field(value, on_invalid) {
244 policy.push_unique(rule);
245 }
246 }
247 policy
248}
249
250fn parse_tool_rule(
251 path: Vec<crate::diagnostic::PathSegment>,
252 raw: &str,
253 action: ToolAction,
254 on_invalid: OnInvalid<'_>,
255) -> Option<ToolRule> {
256 match parse_mars_tool_name(raw) {
257 Ok(parsed) => Some(ToolRule::from_parsed(parsed, action)),
258 Err(err) => {
259 on_invalid(path, raw, err.allowed());
260 None
261 }
262 }
263}
264
265fn parse_tools_field(val: &Value, on_invalid: OnInvalid<'_>) -> Vec<ToolRule> {
267 use crate::diagnostic::{PathSegment, key_path};
268 let field = key_path("tools");
269 if permission_replace_entries(val).is_some() {
270 on_invalid(
271 field.clone(),
272 &crate::ordered::plain(val),
273 "plain list or mapping of tool to allow|deny|ask; package profiles cannot replace a permission set",
274 );
275 return Vec::new();
276 }
277 match val {
278 Value::Mapping(mapping) => {
279 let mut rules = Vec::new();
280 for (key, value) in mapping {
281 let Some(tool_name) = key.as_str() else {
282 on_invalid(
283 field.clone(),
284 &crate::ordered::plain(key),
285 "string tool keys",
286 );
287 continue;
288 };
289
290 let mut entry_path = field.clone();
291 entry_path.push(PathSegment::Key(tool_name.to_owned()));
292 let Some(policy) = value.as_str() else {
293 on_invalid(entry_path, &crate::ordered::plain(value), ACTIONS_ALLOWED);
294 continue;
295 };
296 let Some(action) = ToolAction::parse(policy) else {
297 on_invalid(entry_path, policy, ACTIONS_ALLOWED);
298 continue;
299 };
300 if let Some(rule) = parse_tool_rule(entry_path, tool_name, action, on_invalid) {
301 rules.push(rule);
302 }
303 }
304 rules
305 }
306 Value::String(raw) if raw == "allow" || raw == "deny" => {
309 on_invalid(
310 field,
311 raw,
312 "list of tool names or mapping of tool to allow|deny|ask",
313 );
314 Vec::new()
315 }
316 _ => {
317 on_invalid(
318 field,
319 &crate::ordered::plain(val),
320 "mapping of tool to allow|deny|ask",
321 );
322 Vec::new()
323 }
324 }
325}
326
327fn permission_replace_entries(value: &Value) -> Option<&Value> {
328 let mapping = value.as_mapping()?;
329 (mapping.len() == 1)
330 .then(|| mapping.get(Value::String("replace".to_string())))
331 .flatten()
332}
333
334const ACTIONS_ALLOWED: &str = "allow, deny, or ask";