Skip to content

Commit 6f93640

Browse files
committed
fix(skills): support legacy allowed-tools formats
1 parent 3f19952 commit 6f93640

2 files changed

Lines changed: 155 additions & 4 deletions

File tree

core/src/skills/mod.rs

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ pub use validator::{
3333
DefaultSkillValidator, SkillValidationError, SkillValidator, ValidationErrorKind,
3434
};
3535

36-
use serde::{Deserialize, Serialize};
36+
use serde::{de, Deserialize, Deserializer, Serialize};
3737
use std::collections::HashSet;
3838
use std::path::Path;
3939

@@ -101,7 +101,11 @@ pub struct Skill {
101101
pub description: String,
102102

103103
/// Allowed tools (Claude Code format: "Bash(pattern:*), read(*)")
104-
#[serde(default, rename = "allowed-tools")]
104+
#[serde(
105+
default,
106+
rename = "allowed-tools",
107+
deserialize_with = "deserialize_allowed_tools"
108+
)]
105109
pub allowed_tools: Option<String>,
106110

107111
/// Whether to disable model invocation
@@ -183,11 +187,25 @@ impl Skill {
183187
return permissions;
184188
};
185189

186-
// Parse comma-separated tool permissions
187-
for part in allowed.split(',') {
190+
// Parse Claude-style comma-separated permissions, plus legacy
191+
// whitespace-only tool lists such as "Read Write Edit Bash".
192+
let parts: Vec<&str> = if allowed.contains(',') {
193+
allowed.split(',').collect()
194+
} else {
195+
allowed.split_whitespace().collect()
196+
};
197+
for part in parts {
188198
let part = part.trim();
199+
if part.is_empty() {
200+
continue;
201+
}
189202
if let Some(perm) = ToolPermission::parse(part) {
190203
permissions.insert(perm);
204+
} else {
205+
permissions.insert(ToolPermission {
206+
tool: part.to_string(),
207+
pattern: "*".to_string(),
208+
});
191209
}
192210
}
193211

@@ -218,6 +236,34 @@ impl Skill {
218236
}
219237
}
220238

239+
fn deserialize_allowed_tools<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
240+
where
241+
D: Deserializer<'de>,
242+
{
243+
let value = Option::<serde_yaml::Value>::deserialize(deserializer)?;
244+
match value {
245+
None | Some(serde_yaml::Value::Null) => Ok(None),
246+
Some(serde_yaml::Value::String(s)) => Ok(Some(s)),
247+
Some(serde_yaml::Value::Sequence(items)) => {
248+
let mut tools = Vec::new();
249+
for item in items {
250+
match item {
251+
serde_yaml::Value::String(s) => tools.push(s),
252+
other => {
253+
return Err(de::Error::custom(format!(
254+
"allowed-tools list entries must be strings, got {other:?}"
255+
)));
256+
}
257+
}
258+
}
259+
Ok(Some(tools.join(", ")))
260+
}
261+
Some(other) => Err(de::Error::custom(format!(
262+
"allowed-tools must be a string or a list of strings, got {other:?}"
263+
))),
264+
}
265+
}
266+
221267
#[cfg(test)]
222268
mod tests {
223269
use super::*;
@@ -270,6 +316,51 @@ You are a test assistant.
270316
assert_eq!(permissions.len(), 3);
271317
}
272318

319+
#[test]
320+
fn test_parse_legacy_whitespace_allowed_tools() {
321+
let skill = Skill {
322+
name: "test".to_string(),
323+
description: "test".to_string(),
324+
allowed_tools: Some("Read Write Edit Bash".to_string()),
325+
disable_model_invocation: false,
326+
kind: SkillKind::Instruction,
327+
content: String::new(),
328+
tags: Vec::new(),
329+
version: None,
330+
};
331+
332+
let permissions = skill.parse_allowed_tools();
333+
assert_eq!(permissions.len(), 4);
334+
assert!(permissions
335+
.iter()
336+
.any(|perm| perm.tool == "Bash" && perm.pattern == "*"));
337+
}
338+
339+
#[test]
340+
fn test_parse_allowed_tools_yaml_list() {
341+
let content = r#"---
342+
name: test-skill
343+
description: A test skill
344+
allowed-tools:
345+
- Read
346+
- Write
347+
- Bash(uv run skills analyze-ci:*)
348+
---
349+
# Instructions
350+
"#;
351+
352+
let skill = Skill::parse(content).unwrap();
353+
assert_eq!(
354+
skill.allowed_tools.as_deref(),
355+
Some("Read, Write, Bash(uv run skills analyze-ci:*)")
356+
);
357+
let permissions = skill.parse_allowed_tools();
358+
assert_eq!(permissions.len(), 3);
359+
assert!(permissions
360+
.iter()
361+
.any(|perm| perm.tool == "Read" && perm.pattern == "*"));
362+
}
363+
273364
#[test]
274365
fn test_is_tool_allowed() {
275366
let skill = Skill {

core/src/tools/skill.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,16 @@ impl SkillTool {
226226
fn create_skill_permission_policy(skill: &Skill) -> PermissionPolicy {
227227
let permissions = skill.parse_allowed_tools();
228228

229+
if permissions.is_empty() {
230+
return PermissionPolicy {
231+
deny: Vec::new(),
232+
allow: Vec::new(),
233+
ask: Vec::new(),
234+
default_decision: PermissionDecision::Allow,
235+
enabled: true,
236+
};
237+
}
238+
229239
// Convert skill permissions to PermissionRules
230240
let mut allow_rules = Vec::new();
231241
for perm in permissions {
@@ -453,6 +463,56 @@ mod tests {
453463
);
454464
}
455465

466+
#[test]
467+
fn test_skill_permission_policy_allows_when_unspecified() {
468+
let skill = Skill {
469+
name: "test-skill".to_string(),
470+
description: "Test".to_string(),
471+
allowed_tools: None,
472+
disable_model_invocation: false,
473+
kind: SkillKind::Instruction,
474+
content: String::new(),
475+
tags: Vec::new(),
476+
version: None,
477+
};
478+
479+
let policy = SkillTool::create_skill_permission_policy(&skill);
480+
481+
assert_eq!(
482+
policy.check("bash", &serde_json::json!({"command": "python --version"})),
483+
PermissionDecision::Allow
484+
);
485+
assert_eq!(
486+
policy.check("read", &serde_json::json!({"file_path": "SKILL.md"})),
487+
PermissionDecision::Allow
488+
);
489+
}
490+
491+
#[test]
492+
fn test_skill_permission_policy_accepts_legacy_allowed_tools() {
493+
let skill = Skill {
494+
name: "test-skill".to_string(),
495+
description: "Test".to_string(),
496+
allowed_tools: Some("Read Write Edit Bash".to_string()),
497+
disable_model_invocation: false,
498+
kind: SkillKind::Instruction,
499+
content: String::new(),
500+
tags: Vec::new(),
501+
version: None,
502+
};
503+
504+
let policy = SkillTool::create_skill_permission_policy(&skill);
505+
506+
assert_eq!(
507+
policy.check("bash", &serde_json::json!({"command": "python --version"})),
508+
PermissionDecision::Allow
509+
);
510+
assert_eq!(
511+
policy.check("grep", &serde_json::json!({"pattern": "x"})),
512+
PermissionDecision::Deny
513+
);
514+
}
515+
456516
#[test]
457517
fn test_skill_args_accepts_documented_shape() {
458518
let args =

0 commit comments

Comments
 (0)