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