@@ -9,7 +9,7 @@ use crate::diagnostics::{Diagnostic, Fix};
99use crate :: parsers:: frontmatter:: split_frontmatter;
1010use crate :: rules:: { Validator , ValidatorMetadata } ;
1111use rust_i18n:: t;
12- use std:: path:: Path ;
12+ use std:: path:: { Component , Path , PathBuf } ;
1313
1414/// Known clients that host SKILL.md files.
1515///
@@ -119,6 +119,90 @@ pub(crate) fn detect_client(path: &Path) -> SkillClient {
119119 SkillClient :: Unknown
120120}
121121
122+ /// Normalize a plugin component path without allowing it to escape the plugin
123+ /// root. Claude accepts both `/` and `\` separators in manifest paths.
124+ fn safe_plugin_relative_path ( raw : & str ) -> Option < PathBuf > {
125+ let trimmed = raw. trim ( ) ;
126+ if trimmed. is_empty ( ) {
127+ return None ;
128+ }
129+
130+ let normalized = trimmed. replace ( '\\' , "/" ) ;
131+ let bytes = normalized. as_bytes ( ) ;
132+ if bytes. len ( ) >= 2 && bytes[ 0 ] . is_ascii_alphabetic ( ) && bytes[ 1 ] == b':' {
133+ return None ;
134+ }
135+
136+ let mut relative = PathBuf :: new ( ) ;
137+ for component in Path :: new ( & normalized) . components ( ) {
138+ match component {
139+ Component :: CurDir => { }
140+ Component :: Normal ( part) => relative. push ( part) ,
141+ Component :: ParentDir | Component :: RootDir | Component :: Prefix ( _) => return None ,
142+ }
143+ }
144+ Some ( relative)
145+ }
146+
147+ /// Return whether `path` is a skill owned by a Claude Code plugin.
148+ ///
149+ /// Claude always scans the plugin root's `skills/` directory and loads any
150+ /// additional string or array paths declared by the manifest's `skills` field.
151+ /// Require the documented sibling manifest so a generic `skills/foo/SKILL.md`
152+ /// tree is not misclassified as Claude-owned.
153+ fn is_claude_plugin_skill ( path : & Path , config : & LintConfig ) -> bool {
154+ if path. file_name ( ) . and_then ( |name| name. to_str ( ) ) != Some ( "SKILL.md" ) {
155+ return false ;
156+ }
157+
158+ let Some ( parent) = path. parent ( ) else {
159+ return false ;
160+ } ;
161+
162+ for plugin_root in parent. ancestors ( ) {
163+ let manifest_path = plugin_root. join ( ".claude-plugin" ) . join ( "plugin.json" ) ;
164+ if !config. fs ( ) . is_file ( & manifest_path) {
165+ continue ;
166+ }
167+
168+ if path. starts_with ( plugin_root. join ( "skills" ) ) {
169+ return true ;
170+ }
171+
172+ let Ok ( manifest_content) = config. fs ( ) . read_to_string ( & manifest_path) else {
173+ continue ;
174+ } ;
175+ let Ok ( manifest) = serde_json:: from_str :: < serde_json:: Value > ( & manifest_content) else {
176+ continue ;
177+ } ;
178+ let Some ( declared_skills) = manifest. get ( "skills" ) else {
179+ continue ;
180+ } ;
181+
182+ let declared_paths: Vec < & str > = match declared_skills {
183+ serde_json:: Value :: String ( path) => vec ! [ path] ,
184+ serde_json:: Value :: Array ( paths) => {
185+ paths. iter ( ) . filter_map ( serde_json:: Value :: as_str) . collect ( )
186+ }
187+ _ => Vec :: new ( ) ,
188+ } ;
189+
190+ if declared_paths. into_iter ( ) . any ( |declared| {
191+ safe_plugin_relative_path ( declared) . is_some_and ( |relative| {
192+ if relative. as_os_str ( ) . is_empty ( ) {
193+ path. parent ( ) == Some ( plugin_root)
194+ } else {
195+ path. starts_with ( plugin_root. join ( relative) )
196+ }
197+ } )
198+ } ) {
199+ return true ;
200+ }
201+ }
202+
203+ false
204+ }
205+
122206/// Map a `tools = [...]` entry (or `--tools` value) to a [`SkillClient`].
123207/// Matching is case-insensitive to tolerate configs that use different casing.
124208fn skill_client_from_tool_str ( tool : & str ) -> Option < SkillClient > {
@@ -151,6 +235,9 @@ pub(crate) fn resolve_skill_client(path: &Path, config: &LintConfig) -> SkillCli
151235 if by_path != SkillClient :: Unknown {
152236 return by_path;
153237 }
238+ if is_claude_plugin_skill ( path, config) {
239+ return SkillClient :: ClaudeCode ;
240+ }
154241
155242 let mut from_tools = config
156243 . tools ( )
@@ -303,7 +390,7 @@ impl Validator for PerClientSkillValidator {
303390 return diagnostics;
304391 }
305392
306- let client = detect_client ( path) ;
393+ let client = resolve_skill_client ( path, config ) ;
307394
308395 let per_client_rule = rule_id_for_client ( client) ;
309396 let has_per_client = per_client_rule
@@ -437,6 +524,7 @@ mod tests {
437524 use super :: * ;
438525 use crate :: config:: LintConfig ;
439526 use crate :: rules:: Validator ;
527+ use std:: fs;
440528
441529 fn make_skill ( frontmatter : & str , body : & str ) -> String {
442530 format ! ( "---\n {}\n ---\n {}" , frontmatter, body)
@@ -552,6 +640,101 @@ mod tests {
552640 ) ;
553641 }
554642
643+ #[ test]
644+ fn test_resolve_skill_client_claude_plugin_layouts ( ) {
645+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
646+ let plugin_root = temp. path ( ) . join ( "my-plugin" ) ;
647+ let manifest = plugin_root. join ( ".claude-plugin" ) . join ( "plugin.json" ) ;
648+ fs:: create_dir_all ( manifest. parent ( ) . unwrap ( ) ) . unwrap ( ) ;
649+ fs:: write ( & manifest, "{}" ) . unwrap ( ) ;
650+
651+ assert_eq ! (
652+ resolve_skill_client(
653+ & plugin_root. join( "skills/review/SKILL.md" ) ,
654+ & LintConfig :: default ( )
655+ ) ,
656+ SkillClient :: ClaudeCode
657+ ) ;
658+
659+ assert_eq ! (
660+ resolve_skill_client( & plugin_root. join( "SKILL.md" ) , & LintConfig :: default ( ) ) ,
661+ SkillClient :: Unknown ,
662+ "a root skill must be declared with skills: \" .\" "
663+ ) ;
664+
665+ fs:: write ( & manifest, r#"{"skills":"."}"# ) . unwrap ( ) ;
666+ assert_eq ! (
667+ resolve_skill_client( & plugin_root. join( "SKILL.md" ) , & LintConfig :: default ( ) ) ,
668+ SkillClient :: ClaudeCode
669+ ) ;
670+ assert_eq ! (
671+ resolve_skill_client( & plugin_root. join( "docs/SKILL.md" ) , & LintConfig :: default ( ) ) ,
672+ SkillClient :: Unknown ,
673+ "skills: \" .\" must not claim nested SKILL.md files"
674+ ) ;
675+ }
676+
677+ #[ test]
678+ fn test_resolve_skill_client_claude_plugin_custom_skill_paths ( ) {
679+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
680+ let plugin_root = temp. path ( ) . join ( "my-plugin" ) ;
681+ let manifest = plugin_root. join ( ".claude-plugin" ) . join ( "plugin.json" ) ;
682+ fs:: create_dir_all ( manifest. parent ( ) . unwrap ( ) ) . unwrap ( ) ;
683+ fs:: write (
684+ & manifest,
685+ r#"{"skills":["./custom-skills",".\\more-skills"]}"# ,
686+ )
687+ . unwrap ( ) ;
688+
689+ for relative in [
690+ "custom-skills/review/SKILL.md" ,
691+ "more-skills/deploy/SKILL.md" ,
692+ ] {
693+ assert_eq ! (
694+ resolve_skill_client( & plugin_root. join( relative) , & LintConfig :: default ( ) ) ,
695+ SkillClient :: ClaudeCode
696+ ) ;
697+ }
698+ }
699+
700+ #[ test]
701+ fn test_resolve_skill_client_rejects_escaping_plugin_skill_path ( ) {
702+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
703+ let plugin_root = temp. path ( ) . join ( "my-plugin" ) ;
704+ let manifest = plugin_root. join ( ".claude-plugin" ) . join ( "plugin.json" ) ;
705+ fs:: create_dir_all ( manifest. parent ( ) . unwrap ( ) ) . unwrap ( ) ;
706+ fs:: write ( & manifest, r#"{"skills":"../external-skills"}"# ) . unwrap ( ) ;
707+
708+ assert_eq ! (
709+ resolve_skill_client(
710+ & temp. path( ) . join( "external-skills/review/SKILL.md" ) ,
711+ & LintConfig :: default ( )
712+ ) ,
713+ SkillClient :: Unknown
714+ ) ;
715+
716+ fs:: write ( & manifest, r#"{"skills":"C:\\external-skills"}"# ) . unwrap ( ) ;
717+ assert_eq ! (
718+ resolve_skill_client(
719+ & plugin_root. join( "C:/external-skills/review/SKILL.md" ) ,
720+ & LintConfig :: default ( )
721+ ) ,
722+ SkillClient :: Unknown
723+ ) ;
724+ }
725+
726+ #[ test]
727+ fn test_resolve_skill_client_generic_skills_without_plugin_manifest ( ) {
728+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
729+ assert_eq ! (
730+ resolve_skill_client(
731+ & temp. path( ) . join( "skills/review/SKILL.md" ) ,
732+ & LintConfig :: default ( )
733+ ) ,
734+ SkillClient :: Unknown
735+ ) ;
736+ }
737+
555738 // ===== Validation tests =====
556739
557740 #[ test]
@@ -598,6 +781,28 @@ mod tests {
598781 ) ;
599782 }
600783
784+ #[ test]
785+ fn test_claude_plugin_custom_skill_skips_cross_platform_warning ( ) {
786+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
787+ let plugin_root = temp. path ( ) . join ( "my-plugin" ) ;
788+ let manifest = plugin_root. join ( ".claude-plugin" ) . join ( "plugin.json" ) ;
789+ let skill_path = plugin_root. join ( "custom-skills/review/SKILL.md" ) ;
790+ fs:: create_dir_all ( manifest. parent ( ) . unwrap ( ) ) . unwrap ( ) ;
791+ fs:: write ( & manifest, r#"{"skills":"./custom-skills"}"# ) . unwrap ( ) ;
792+
793+ let content = make_skill (
794+ "description: Review changes\n disallowed-tools: Bash" ,
795+ "Body" ,
796+ ) ;
797+ let diagnostics =
798+ PerClientSkillValidator . validate ( & skill_path, & content, & LintConfig :: default ( ) ) ;
799+
800+ assert ! (
801+ !diagnostics. iter( ) . any( |d| d. rule == "XP-SK-001" ) ,
802+ "Claude-only fields in manifest-owned skills are not portability issues: {diagnostics:?}"
803+ ) ;
804+ }
805+
601806 #[ test]
602807 fn test_cursor_unsupported_model ( ) {
603808 let content = make_skill ( "name: my-skill\n description: A test\n model: opus" , "Body" ) ;
0 commit comments