Skip to main content

mars_schema/
tool_policy.rs

1//! Canonical authored tool-policy values and frontmatter parsing.
2
3use 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/// What a rule does when its capability (and pattern) match.
9#[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/// One tool-policy statement: `action` for `capability`, optionally narrowed by `pattern`.
36///
37/// Rules are kept in profile order, which carries no precedence: a broad rule against a scoped
38/// one is the consuming harness's call, and [`EffectiveToolPolicy::project`] settles only
39/// capabilities one harness tool cannot tell apart.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ToolRule {
42    pub capability: Capability,
43    /// Scope inside `capability(...)`. `None` for `*` and `mcp`, whose scope is the capability.
44    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    /// Canonical `capability(pattern)` spelling, for diagnostics and frontmatter.
66    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    /// The pattern as the bundle and `show --json` print it: an `mcp` rule's `server/tool`.
74    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
82/// The rule's wire shape, shared by the launch bundle's `tools.rules` and `agents show --json`:
83/// `{capability, pattern, action}`. `capability` is `*` for "every other tool" and
84/// `mcp` for an MCP ref, whose `pattern` is its `server/tool`.
85impl 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/// The tool rules of an agent or skill.
96#[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
134/// Append whole-server `mcp(server)` entries to the frontmatter `tools:` list or map.
135pub 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        // Map form: an absent `tools:` inherits every tool, and a list here would read as an
148        // allowlist of only these MCP servers.
149        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
192/// The tool names in a `tools:` / `disallowed-tools:` value. A scalar is Claude's own
193/// agent-file spelling, `tools: Read, Grep`: a comma-separated list. Commas inside a scope
194/// (`bash(rg a,b)`) belong to the entry.
195pub(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            // Claude writes tool lists comma- or space-separated; parentheses stay whole.
207            ',' | ' ' | '\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
237/// Read a v1 profile's `tools:` action map into one policy.
238///
239/// `on_invalid` gets `(field, value, allowed)` for each entry Mars rejects.
240pub 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
265/// Parse the v1 `tools:` action map.
266fn 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        // `tools: allow` / `tools: deny` was a Meridian-side shorthand that never worked
307        // here: it read as a tool named `allow`. Say so instead of guessing.
308        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";