1use crate::providers::ToolDefinition;
35use crate::skills::SkillRegistry;
36
37const META_TOOLS: &[&str] = &[
42 "ActivateSkill",
43 "ListSkills",
44 "ListAgents",
45 "InvokeAgent",
46 "AskUser",
47];
48
49#[derive(Debug, Default)]
54pub struct SkillToolScope {
55 allowed: Option<Vec<String>>,
57}
58
59impl SkillToolScope {
60 pub fn new() -> Self {
62 Self { allowed: None }
63 }
64
65 pub fn update_from_tool_calls(
70 &mut self,
71 tool_calls: &[(String, serde_json::Value)],
72 registry: &SkillRegistry,
73 ) {
74 for (name, args) in tool_calls {
75 if name == "ActivateSkill"
76 && let Some(skill_name) = args.get("skill_name").and_then(|v| v.as_str())
77 && let Some(skill) = registry.get(skill_name)
78 {
79 if skill.meta.allowed_tools.is_empty() {
80 self.allowed = None;
81 } else {
82 self.allowed = Some(skill.meta.allowed_tools.clone());
83 }
84 }
85 }
86 }
87
88 pub fn is_active(&self) -> bool {
90 self.allowed.is_some()
91 }
92
93 pub fn filter_tool_defs(&self, tool_defs: &[ToolDefinition]) -> Vec<ToolDefinition> {
98 match &self.allowed {
99 None => tool_defs.to_vec(),
100 Some(allowed) => tool_defs
101 .iter()
102 .filter(|td| allowed.contains(&td.name) || META_TOOLS.contains(&td.name.as_str()))
103 .cloned()
104 .collect(),
105 }
106 }
107
108 pub fn check_tool_call(&self, tool_name: &str) -> Option<String> {
112 match &self.allowed {
113 None => None,
114 Some(allowed) => {
115 if allowed.iter().any(|t| t == tool_name) || META_TOOLS.contains(&tool_name) {
116 None
117 } else {
118 Some(format!(
119 "Tool '{tool_name}' is not allowed by the active skill scope. \
120 Allowed tools: {}. \
121 Use ActivateSkill to switch to a different skill.",
122 allowed.join(", ")
123 ))
124 }
125 }
126 }
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use crate::skills::{Skill, SkillMeta, SkillSource};
134
135 fn make_registry() -> SkillRegistry {
136 let mut registry = SkillRegistry::default();
137 registry.skills.insert(
139 "scoped".to_string(),
140 Skill {
141 meta: SkillMeta {
142 name: "scoped".to_string(),
143 description: "Scoped".to_string(),
144 tags: vec![],
145 when_to_use: None,
146 allowed_tools: vec!["Read".to_string(), "Grep".to_string()],
147 user_invocable: true,
148 argument_hint: None,
149 source: SkillSource::BuiltIn,
150 },
151 content: "scoped content".to_string(),
152 },
153 );
154 registry.add_builtin("unscoped", "Unscoped", None, "unscoped content");
156 registry
157 }
158
159 fn tool_defs() -> Vec<ToolDefinition> {
160 [
161 "Read",
162 "Grep",
163 "Write",
164 "Bash",
165 "ActivateSkill",
166 "ListSkills",
167 ]
168 .iter()
169 .map(|n| ToolDefinition {
170 name: n.to_string(),
171 description: format!("{n} tool"),
172 parameters: serde_json::json!({}),
173 })
174 .collect()
175 }
176
177 #[test]
178 fn test_no_scope_passes_all_tools() {
179 let scope = SkillToolScope::new();
180 let defs = tool_defs();
181 let filtered = scope.filter_tool_defs(&defs);
182 assert_eq!(filtered.len(), defs.len());
183 }
184
185 #[test]
186 fn test_activate_scoped_skill_filters_tools() {
187 let registry = make_registry();
188 let mut scope = SkillToolScope::new();
189 let calls = vec![(
190 "ActivateSkill".to_string(),
191 serde_json::json!({"skill_name": "scoped"}),
192 )];
193 scope.update_from_tool_calls(&calls, ®istry);
194
195 assert!(scope.is_active());
196
197 let defs = tool_defs();
198 let filtered = scope.filter_tool_defs(&defs);
199 let names: Vec<&str> = filtered.iter().map(|d| d.name.as_str()).collect();
200 assert!(names.contains(&"Read"));
201 assert!(names.contains(&"Grep"));
202 assert!(names.contains(&"ActivateSkill")); assert!(names.contains(&"ListSkills")); assert!(!names.contains(&"Write")); assert!(!names.contains(&"Bash")); }
207
208 #[test]
209 fn test_activate_unscoped_skill_clears_scope() {
210 let registry = make_registry();
211 let mut scope = SkillToolScope::new();
212
213 scope.update_from_tool_calls(
215 &[(
216 "ActivateSkill".to_string(),
217 serde_json::json!({"skill_name": "scoped"}),
218 )],
219 ®istry,
220 );
221 assert!(scope.is_active());
222
223 scope.update_from_tool_calls(
225 &[(
226 "ActivateSkill".to_string(),
227 serde_json::json!({"skill_name": "unscoped"}),
228 )],
229 ®istry,
230 );
231 assert!(!scope.is_active());
232 }
233
234 #[test]
235 fn test_check_tool_call_allowed() {
236 let registry = make_registry();
237 let mut scope = SkillToolScope::new();
238 scope.update_from_tool_calls(
239 &[(
240 "ActivateSkill".to_string(),
241 serde_json::json!({"skill_name": "scoped"}),
242 )],
243 ®istry,
244 );
245
246 assert!(scope.check_tool_call("Read").is_none());
247 assert!(scope.check_tool_call("Grep").is_none());
248 assert!(scope.check_tool_call("ActivateSkill").is_none()); assert!(scope.check_tool_call("AskUser").is_none()); let err = scope.check_tool_call("Write");
252 assert!(err.is_some());
253 assert!(err.unwrap().contains("not allowed"));
254 }
255
256 #[test]
257 fn test_no_scope_allows_everything() {
258 let scope = SkillToolScope::new();
259 assert!(scope.check_tool_call("Write").is_none());
260 assert!(scope.check_tool_call("Bash").is_none());
261 assert!(scope.check_tool_call("anything").is_none());
262 }
263
264 #[test]
265 fn test_unknown_skill_preserves_scope() {
266 let registry = make_registry();
267 let mut scope = SkillToolScope::new();
268
269 scope.update_from_tool_calls(
271 &[(
272 "ActivateSkill".to_string(),
273 serde_json::json!({"skill_name": "scoped"}),
274 )],
275 ®istry,
276 );
277 assert!(scope.is_active());
278
279 scope.update_from_tool_calls(
281 &[(
282 "ActivateSkill".to_string(),
283 serde_json::json!({"skill_name": "nope"}),
284 )],
285 ®istry,
286 );
287 assert!(scope.is_active());
288 }
289
290 #[test]
291 fn test_non_activate_calls_ignored() {
292 let registry = make_registry();
293 let mut scope = SkillToolScope::new();
294 scope.update_from_tool_calls(
295 &[(
296 "Read".to_string(),
297 serde_json::json!({"file_path": "foo.rs"}),
298 )],
299 ®istry,
300 );
301 assert!(!scope.is_active());
302 }
303}