fix(transport): validate skill names in ClaudeAgentOptions.skills#1145
fix(transport): validate skill names in ClaudeAgentOptions.skills#1145ant-kurt wants to merge 3 commits into
Conversation
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.
Also document in the skills docstring that listed names must be exact.
A tuple or generator built Skill(name) rules but was dropped from the initialize request, so no skill filter was installed. U+FEFF is whitespace to the CLI's trim but not to str.strip(), so a BOM'd name built a rule that could never match.
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
src/claude_agent_sdk/_internal/transport/subprocess_cli.py:534-542— All skill-name validation added by this PR lives insideSubprocessCLITransport._apply_skills_defaults, so the public custom-transport entry points (ClaudeSDKClient(transport=...)andquery(transport=...)) bypass it entirely —Query.initializethen sendsoptions.skillsverbatim as the session skills filter, soskills=[" pdf"]or["/pdf"]silently hides every skill andskills="pdf"is silently dropped (fail-open), contradicting the new types.py docstring promise that invalid names "raise atconnect()". Consider hoisting the transport-independent checks (bare string, non-string entries, empty, surrounding whitespace, leading/, surrogates, control chars) intoQueryor the shared client layer so every transport fails closed.Extended reasoning...
What the bug is
This PR adds
_reject_bare_string_skillsand_validate_skill_name, but both are private helpers ofSubprocessCLITransport, invoked only from_build_command()→_apply_skills_defaults()(src/claude_agent_sdk/_internal/transport/subprocess_cli.py:534,541). Skill names have a second, SDK-owned consumer that is reachable without ever constructing that transport:Query.initializesendsrequest["skills"] = self._skillsverbatim (src/claude_agent_sdk/_internal/query.py:237-240) whenever the value is a list — with no validation of any kind.The code path that triggers it
Both public custom-transport entry points route around the new guards entirely:
ClaudeSDKClient(transport=...):client.py:188-189uses the caller's transport directly (noSubprocessCLITransportis built), thenclient.py:240passesskills=self.options.skillsintoQuery.query(transport=...):_internal/client.py:128-129does the same, then line 185 passesskills=configured_options.skills.
A custom
Transportis a public, documented constructor parameter (e.g. a transport tunneling the same CLI over SSH or WebSocket), so this is a supported path, not an internal one.Step-by-step proof
- A user constructs
ClaudeSDKClient(transport=my_transport, options=ClaudeAgentOptions(skills=[" pdf"])). connect()takes theself._custom_transportbranch —SubprocessCLITransport._build_command()never runs, so neither_reject_bare_string_skillsnor_validate_skill_nameexecutes. Nothing raises.Query.initializeseesisinstance([" pdf"], list)is true and sendsrequest["skills"] = [" pdf"]to the CLI.- The CLI installs the session skills filter and matches it verbatim against discovered skill names (per this PR's own leading-slash rationale: "The session allowlist matches verbatim"). No discovered name has leading whitespace, so the filter matches nothing — every skill is silently hidden.
The same holds for
["/pdf"]and[42](sent as-is), whileskills="pdf"fails theisinstance(..., list)gate and is silently dropped from initialize — fail-open, no filter installed at all.Why this matters
Step 4 is exactly the silently-dead-config failure mode this PR sets out to eliminate, and it contradicts the unconditional promise the PR adds to the
ClaudeAgentOptions.skillsdocstring intypes.py: "Names must be exact: wildcards, delimiters, and surrounding whitespace raise atconnect()." That promise only holds for the default subprocess transport. Note this is distinct from the existing bot comment about widening_reject_bare_string_skillsfor tuple/set/generator: that fix lives inside the transport and never executes on the custom-transport path — the root cause here is validation placement (coupled to oneTransportimplementation), not guard breadth. The dead-rule checks (whitespace, leading/, surrogates) are justified by session-filter verbatim matching, which happens in SDK-ownedQuery.initializeregardless of transport — so scoping them to one transport is not an intentional design boundary.How to fix
Hoist the transport-independent subset of the checks — bare string, non-string entries, empty names, surrounding whitespace, leading
/, surrogates, control characters — intoQuery.__init__/initializeor the two shared client call sites, so every transport fails closed as documented. The subprocess transport can keep the--allowedTools-tokenizer-specific checks (parentheses, commas, backslash rules, wildcard suffixes) as defense in depth.Why nit rather than blocking
Custom transports are a niche path, the initialize
skillsfield is structured JSON (no injection surface), and the outcome for these inputs is identical to pre-PR behavior — silent unavailability, not a regression. This is a completeness/doc-contract gap in the new validation net, not a defect that breaks anything that previously worked.
There was a problem hiding this comment.
Warning
This review may be incomplete: some analysis steps could not run due to a temporary API capacity limit.
I didn't find any new bugs in this revision — my two earlier comments (non-list skills values slipping past the bare-string guard, and the U+FEFF trim divergence) are both addressed: _reject_non_list_skills now rejects tuple/set/generator values to mirror the isinstance(..., list) gate in query.py, and U+FEFF is folded into _SKILL_NAME_INVALID_CHARS, each with new parametrized tests. The "all" sentinel is also now a shared constant per the earlier review suggestion. I'm leaving the final sign-off to a human because the PR intentionally changes public behavior (skills=["*"], plugin:*, and padded names now raise) and constructs permission rules.
What was reviewed:
- Every rejection branch in
_validate_skill_nameagainst the new tests — each check only narrows what reaches--allowedTools; accepted names build argv identical to before. - Cross-checked the transport guard against
Query.initialize'sskillsgating so both consumers ofoptions.skillsnow agree. - Checked
_SKILLS_ALLusages intypes.py(shadow-warning path) and the transport for consistency.
Extended reasoning...
The PR adds fail-closed validation of ClaudeAgentOptions.skills in the subprocess transport before names are formatted into --allowedTools, plus a shared _SKILLS_ALL constant and ~200 lines of parametrized tests. All three pieces of prior review feedback (two bot nits, one human suggestion) are resolved in this revision, and the bug hunting system found no new issues.
The change is strictly narrowing: every new code path either raises or preserves the previous argv byte-for-byte for accepted names, so there is no way for it to widen permissions. The security direction is therefore safe — the residual risk is false rejection of a legitimate skill name, not injection. However, the PR is a deliberate breaking change to a public option (wildcards, padded names, and slash-prefixed names now raise at connect), and several checks encode claims about the CLI's tokenizer and Skill-tool trim semantics that cannot be verified from this repository alone. Those are product/design calls a maintainer should confirm, and a human reviewer is already engaged on the thread.
Scrutiny level: this is permission-rule construction, which the approval guidelines treat as security-sensitive, and it carries documented breaking behavior — both disqualify shadow-approval regardless of code quality. Test coverage is thorough (rejection and acceptance cases for every branch, including the shapes from prior findings), and the author reports the full suite, ruff, and mypy clean.
Summary
Skill names from
ClaudeAgentOptions(skills=[...])were formatted into the--allowedToolsvalue unchecked. The CLI splits that value into permission 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. The transport now validates each name and fails closed.Rejected: parentheses, commas, control characters (C0, DEL, C1), empty names, and a bare string passed in place of a list; a literal
*and wildcard suffixes (:*,*); and shapes that parse but can never match the listed skill — surrounding whitespace, a leading/, consecutive backslashes, a trailing unpaired backslash, and surrogate code points.Ordinary names are unaffected, including plugin-qualified names, interior spaces, single backslashes, and non-ASCII.
Breaking:
skills=["plugin:*"]andskills=["*"]now raise — useskills="all", or aSkill(...)rule inallowed_toolsfor prefix matching.skills=[" name"]andskills=["/name"]now raise as well; both previously built a rule that could never match, so the skill was silently unavailable.Test plan
python -m pytest tests/— 1349 passed, 4 skipped, including new parametrized rejection and acceptance cases intests/test_transport.py.ruff check,ruff format --check, andmypy src/are clean. Separately droveClaudeSDKClientandquery()to confirm each rejected shape raises at connect while ordinary names build identical argv to before.