Skip to content

Commit e6756f1

Browse files
committed
fix(transport): validate skill names before they enter --allowedTools
Skill names from ClaudeAgentOptions(skills=[...]) were formatted into the --allowedTools value unchecked. The CLI splits that value into rules on commas and spaces outside parentheses, and its tokenizer honors no escape sequences, so a name carrying one of those delimiters cannot be passed through reliably. Reject those at the transport boundary, along with wildcard forms and names that parse but can never match the listed skill. Ordinary names are unaffected, including plugin-qualified, interior-space, and non-ASCII names.
1 parent e6e07f1 commit e6756f1

2 files changed

Lines changed: 265 additions & 0 deletions

File tree

src/claude_agent_sdk/_internal/transport/subprocess_cli.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,88 @@ def _kill_active_children() -> None:
5555
atexit.register(_kill_active_children)
5656

5757

58+
# Parentheses and commas are delimiters to the --allowedTools tokenizer;
59+
# control characters (C0, DEL, C1) never appear in a skill directory name.
60+
_SKILL_NAME_INVALID_CHARS = re.compile(r"[(),\x00-\x1f\x7f-\x9f]")
61+
62+
# Every surrogate in a Python str is unpaired by construction: a well-formed
63+
# astral character is a single code point, not a pair.
64+
_SURROGATE_RE = re.compile("[\ud800-\udfff]")
65+
66+
67+
def _reject_bare_string_skills(skills: object) -> None:
68+
"""Reject a string in place of a list, which would iterate as characters."""
69+
if isinstance(skills, str) and skills != "all":
70+
raise TypeError(
71+
"ClaudeAgentOptions.skills must be a list of skill names or"
72+
f' "all", got the string {skills!r}. Did you mean [{skills!r}]?'
73+
)
74+
75+
76+
def _validate_skill_name(name: str) -> None:
77+
"""Reject skill names that cannot ride safely in a ``Skill(name)`` rule.
78+
79+
Names from ``options.skills`` are formatted into the ``--allowedTools``
80+
value, which the CLI splits into rules on commas and spaces outside
81+
parentheses. That tokenizer does not honor escape sequences -- escaping
82+
exists only in the per-rule grammar, applied after splitting -- so a
83+
name carrying a delimiter cannot be passed through reliably: what it
84+
tokenizes into depends on what surrounds it.
85+
86+
Names that tokenize cleanly but can never match the listed skill are
87+
rejected too, so a dead rule fails loudly here instead of silently
88+
granting nothing. Each check below states its own reason.
89+
"""
90+
if not isinstance(name, str):
91+
raise TypeError(
92+
f"Skill names must be strings, got {type(name).__name__}: {name!r}"
93+
)
94+
if not name.strip():
95+
raise ValueError("Skill names must be non-empty strings")
96+
if _SURROGATE_RE.search(name):
97+
raise ValueError(
98+
f"Invalid skill name {name!r}: contains a surrogate code point,"
99+
" which can never match a skill the CLI discovered."
100+
)
101+
if name != name.strip():
102+
raise ValueError(
103+
f"Invalid skill name {name!r}: leading or trailing whitespace"
104+
" can never match — the Skill tool trims the invoked name."
105+
)
106+
if _SKILL_NAME_INVALID_CHARS.search(name):
107+
raise ValueError(
108+
f"Invalid skill name {name!r}: parentheses, commas, and control"
109+
" characters are not allowed. Names match the skill's directory"
110+
" name, or 'plugin:skill' for plugin-qualified skills."
111+
)
112+
if name == "*":
113+
raise ValueError(
114+
"Invalid skill name '*': use skills=\"all\" to enable every skill."
115+
)
116+
if name.endswith(":*") or name.endswith(" *"):
117+
raise ValueError(
118+
f"Invalid skill name {name!r}: wildcard-suffix names are not"
119+
" allowed; list each skill by its exact name."
120+
)
121+
if name.startswith("/"):
122+
raise ValueError(
123+
f"Invalid skill name {name!r}: skill names may not start with"
124+
" '/'. The skills option takes the canonical name, not the"
125+
" slash-command form."
126+
)
127+
if "\\\\" in name:
128+
raise ValueError(
129+
f"Invalid skill name {name!r}: consecutive backslashes are not"
130+
" allowed — the per-rule parser collapses them, so the rule"
131+
" would name a different skill."
132+
)
133+
if name.endswith("\\"):
134+
raise ValueError(
135+
f"Invalid skill name {name!r}: names may not end with an"
136+
" unpaired backslash."
137+
)
138+
139+
58140
class _LineFramer:
59141
"""Reassembles complete lines from a stream that yields arbitrary chunks.
60142
@@ -429,6 +511,9 @@ def _apply_skills_defaults(
429511
unset so the CLI discovers installed skills without the caller having
430512
to wire up both options manually. ``None`` is a no-op.
431513
514+
Each listed skill name is validated before being formatted into a
515+
rule; see :func:`_validate_skill_name`.
516+
432517
Does not mutate the original options object.
433518
"""
434519
allowed_tools: list[str] = list(self._options.allowed_tools)
@@ -441,12 +526,14 @@ def _apply_skills_defaults(
441526
skills = self._options.skills
442527
if skills is None:
443528
return allowed_tools, setting_sources
529+
_reject_bare_string_skills(skills)
444530

445531
if skills == "all":
446532
if "Skill" not in allowed_tools:
447533
allowed_tools.append("Skill")
448534
else:
449535
for name in skills:
536+
_validate_skill_name(name)
450537
pattern = f"Skill({name})"
451538
if pattern not in allowed_tools:
452539
allowed_tools.append(pattern)

tests/test_transport.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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\nnewline", "with\ttab", "nul\x00byte", "del\x7fchar"],
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 ", "\tpdf", " 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\ud800surrogate", "\udc00leading"])
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\u0085end", "csi\u009bend"])
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

Comments
 (0)