@@ -666,6 +666,184 @@ def test_build_command_skills_does_not_duplicate_entries(self):
666666 cmd = transport ._build_command ()
667667 assert cmd [cmd .index ("--allowedTools" ) + 1 ] == "Skill(pdf)"
668668
669+ @pytest .mark .parametrize (
670+ "hostile_name" ,
671+ [
672+ # Names containing rule-syntax delimiters cannot be represented
673+ # as a single Skill(name) entry and must be rejected, never
674+ # formatted into the --allowedTools value.
675+ "x),Bash(*" ,
676+ "safe),Bash,Skill(dummy" ,
677+ "name,with,commas" ,
678+ "unbalanced(" ,
679+ "unbalanced)" ,
680+ "()" ,
681+ ],
682+ )
683+ def test_build_command_skills_rejects_rule_syntax_delimiters (
684+ self , hostile_name : str
685+ ):
686+ """Skill names containing rule-syntax delimiters raise ValueError."""
687+ transport = SubprocessCLITransport (
688+ prompt = "test" ,
689+ options = make_options (skills = [hostile_name ]),
690+ )
691+ with pytest .raises (ValueError , match = "Invalid skill name" ):
692+ transport ._build_command ()
693+
694+ @pytest .mark .parametrize (
695+ "hostile_name" ,
696+ ["with\n newline" , "with\t tab" , "nul\x00 byte" , "del\x7f char" ],
697+ )
698+ def test_build_command_skills_rejects_control_characters (self , hostile_name : str ):
699+ """Skill names containing control characters raise ValueError."""
700+ transport = SubprocessCLITransport (
701+ prompt = "test" ,
702+ options = make_options (skills = [hostile_name ]),
703+ )
704+ with pytest .raises (ValueError , match = "Invalid skill name" ):
705+ transport ._build_command ()
706+
707+ @pytest .mark .parametrize ("empty_name" , ["" , " " , " \t " ])
708+ def test_build_command_skills_rejects_empty_names (self , empty_name : str ):
709+ """Empty or whitespace-only skill names raise ValueError."""
710+ transport = SubprocessCLITransport (
711+ prompt = "test" ,
712+ options = make_options (skills = [empty_name ]),
713+ )
714+ with pytest .raises (ValueError , match = "non-empty" ):
715+ transport ._build_command ()
716+
717+ def test_build_command_skills_rejects_non_string_names (self ):
718+ """Non-string entries in the skills list raise TypeError."""
719+ transport = SubprocessCLITransport (
720+ prompt = "test" ,
721+ options = make_options (skills = [42 ]), # type: ignore[list-item]
722+ )
723+ with pytest .raises (TypeError , match = "must be strings" ):
724+ transport ._build_command ()
725+
726+ @pytest .mark .parametrize (
727+ "wildcard_name" ,
728+ ["pdf:*" , "my skill *" , ":*" ],
729+ )
730+ def test_build_command_skills_rejects_wildcard_suffix_names (
731+ self , wildcard_name : str
732+ ):
733+ """Wildcard-suffix skill names raise ValueError."""
734+ transport = SubprocessCLITransport (
735+ prompt = "test" ,
736+ options = make_options (skills = [wildcard_name ]),
737+ )
738+ with pytest .raises (ValueError , match = "wildcard-suffix" ):
739+ transport ._build_command ()
740+
741+ @pytest .mark .parametrize ("skills" , ["pdf" , "pdf-tools" , "ALL" ])
742+ def test_build_command_skills_rejects_a_bare_string (self , skills : str ):
743+ """A string is iterable, so skills="pdf" would build Skill(p),Skill(d),..."""
744+ transport = SubprocessCLITransport (
745+ prompt = "test" ,
746+ options = make_options (skills = skills ), # type: ignore[arg-type]
747+ )
748+ with pytest .raises (TypeError , match = "must be a list of skill names" ):
749+ transport ._build_command ()
750+
751+ def test_build_command_skills_rejects_bare_wildcard (self ):
752+ """A literal '*' name raises, pointing to the skills="all" option."""
753+ transport = SubprocessCLITransport (
754+ prompt = "test" ,
755+ options = make_options (skills = ["*" ]),
756+ )
757+ with pytest .raises (ValueError , match = 'use skills="all"' ):
758+ transport ._build_command ()
759+
760+ def test_build_command_skills_rejects_unpaired_trailing_backslash (self ):
761+ """A name ending in a single trailing backslash raises ValueError."""
762+ transport = SubprocessCLITransport (
763+ prompt = "test" ,
764+ options = make_options (skills = ["name\\ " ]),
765+ )
766+ with pytest .raises (ValueError , match = "unpaired backslash" ):
767+ transport ._build_command ()
768+
769+ @pytest .mark .parametrize ("hostile_name" , ["name\\ \\ " , "name\\ \\ \\ " , "mid\\ \\ dle" ])
770+ def test_build_command_skills_rejects_consecutive_backslashes (
771+ self , hostile_name : str
772+ ):
773+ """Consecutive backslashes collapse at parse time, renaming the skill."""
774+ transport = SubprocessCLITransport (
775+ prompt = "test" ,
776+ options = make_options (skills = [hostile_name ]),
777+ )
778+ with pytest .raises (ValueError , match = "consecutive backslashes" ):
779+ transport ._build_command ()
780+
781+ @pytest .mark .parametrize ("hostile_name" , [" pdf" , "pdf " , "\t pdf" , " pdf " ])
782+ def test_build_command_skills_rejects_surrounding_whitespace (
783+ self , hostile_name : str
784+ ):
785+ """A padded rule can never match: the Skill tool trims before matching."""
786+ transport = SubprocessCLITransport (
787+ prompt = "test" ,
788+ options = make_options (skills = [hostile_name ]),
789+ )
790+ with pytest .raises (ValueError , match = "whitespace" ):
791+ transport ._build_command ()
792+
793+ @pytest .mark .parametrize ("hostile_name" , ["/pdf" , "/myplugin:pdf" ])
794+ def test_build_command_skills_rejects_leading_slash (self , hostile_name : str ):
795+ """The session allowlist matches verbatim, so '/pdf' hides every skill."""
796+ transport = SubprocessCLITransport (
797+ prompt = "test" ,
798+ options = make_options (skills = [hostile_name ]),
799+ )
800+ with pytest .raises (ValueError , match = "may not start with" ):
801+ transport ._build_command ()
802+
803+ @pytest .mark .parametrize ("hostile_name" , ["lone\ud800 surrogate" , "\udc00 leading" ])
804+ def test_build_command_skills_rejects_surrogate_code_points (
805+ self , hostile_name : str
806+ ):
807+ """No CLI-discovered skill name contains a surrogate code point."""
808+ transport = SubprocessCLITransport (
809+ prompt = "test" ,
810+ options = make_options (skills = [hostile_name ]),
811+ )
812+ with pytest .raises (ValueError , match = "surrogate" ):
813+ transport ._build_command ()
814+
815+ @pytest .mark .parametrize ("hostile_name" , ["nel\u0085 end" , "csi\u009b end" ])
816+ def test_build_command_skills_rejects_c1_control_characters (
817+ self , hostile_name : str
818+ ):
819+ """Names containing C1 control characters raise ValueError."""
820+ transport = SubprocessCLITransport (
821+ prompt = "test" ,
822+ options = make_options (skills = [hostile_name ]),
823+ )
824+ with pytest .raises (ValueError , match = "Invalid skill name" ):
825+ transport ._build_command ()
826+
827+ @pytest .mark .parametrize (
828+ "benign_name" ,
829+ [
830+ "pdf-tools" ,
831+ "my_skill.v2" ,
832+ "myplugin:pdf" ,
833+ "skill with spaces" ,
834+ "dir\\ sub" ,
835+ "日本語スキル" ,
836+ ],
837+ )
838+ def test_build_command_skills_accepts_ordinary_names (self , benign_name : str ):
839+ """Ordinary names -- plugin-qualified, spaced, non-ASCII -- still work."""
840+ transport = SubprocessCLITransport (
841+ prompt = "test" ,
842+ options = make_options (skills = [benign_name ]),
843+ )
844+ cmd = transport ._build_command ()
845+ assert cmd [cmd .index ("--allowedTools" ) + 1 ] == f"Skill({ benign_name } )"
846+
669847 @pytest .mark .parametrize (
670848 ("skills" , "extra" , "want_tools" , "want_sources" , "want_init_skills" ),
671849 [
0 commit comments