Skip to content

fix(transport): guard against None allowed_tools in _apply_skills_defaults#963

Open
Aispotlightlab wants to merge 1 commit into
anthropics:mainfrom
Aispotlightlab:fix/none-iterable-allowed-tools
Open

fix(transport): guard against None allowed_tools in _apply_skills_defaults#963
Aispotlightlab wants to merge 1 commit into
anthropics:mainfrom
Aispotlightlab:fix/none-iterable-allowed-tools

Conversation

@Aispotlightlab
Copy link
Copy Markdown

Summary

SubprocessCLITransport._apply_skills_defaults calls list(self._options.allowed_tools) unconditionally (subprocess_cli.py:196). While the type annotation declares list[str] with default_factory=list, the ClaudeAgentOptions dataclass does not validate the attribute at runtime — wrapper libraries can (and do) assign None to express "no restriction".

When that happens, every ClaudeSDKClient.connect() dies with TypeError: 'NoneType' object is not iterable before the CLI even starts, and the failure surfaces as the opaque Unexpected error in Claude SDK log line.

Reproduced on both 0.1.81 and 0.2.82 (current latest).

Reproduction

from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions

opts = ClaudeAgentOptions(allowed_tools=None)  # or assign after construction
async with ClaudeSDKClient(options=opts) as c:  # crashes here
    ...

Traceback:

File "…/transport/subprocess_cli.py", line 423, in connect
    cmd = self._build_command()
File "…/transport/subprocess_cli.py", line 253, in _build_command
    self._apply_skills_defaults()
File "…/transport/subprocess_cli.py", line 196, in _apply_skills_defaults
    allowed_tools: list[str] = list(self._options.allowed_tools)
TypeError: 'NoneType' object is not iterable

Real-world impact

claude-code-telegram (a popular wrapper, ~v1.6.0) does exactly this when its DISABLE_TOOL_VALIDATION=true is set — which is the standard recipe in its docs for adding third-party MCP servers (MemPalace, Mem0, custom MCPs, etc.). Every Telegram message then fails. Companion PR on their side: RichardAtCT/claude-code-telegram#206 — but a defensive guard in the SDK protects every other downstream wrapper too.

Fix

-        allowed_tools: list[str] = list(self._options.allowed_tools)
+        allowed_tools: list[str] = list(self._options.allowed_tools or [])

An empty list is the natural "no restriction" sentinel and is already handled correctly downstream — line 256 only emits --allowedTools when the list is truthy.

disallowed_tools is already None-safe (line 265 uses a truthy check), so it needs no change.

Test

Added test_build_command_allowed_tools_none in tests/test_transport.py that:

  • pre-patch: fails with the exact TypeError above
  • post-patch: passes and verifies no --allowedTools flag is emitted
$ pytest tests/test_transport.py::TestSubprocessCLITransport::test_build_command_allowed_tools_none -v
PASSED [100%]

🤖 Generated with Claude Code

`SubprocessCLITransport._apply_skills_defaults` calls
`list(self._options.allowed_tools)` unconditionally. While the type
annotation declares `list[str]` with `default_factory=list`, the
`ClaudeAgentOptions` dataclass does not validate the attribute at
runtime — wrapper libraries can (and do) assign `None` to express
"no restriction".

Real-world impact: `claude-code-telegram` sets
`options.allowed_tools = None` when `DISABLE_TOOL_VALIDATION=true`
(its recipe for adding third-party MCP servers). Every connect
attempt then dies with `TypeError: 'NoneType' object is not
iterable` inside `_build_command` — before the CLI even starts —
and the failure surfaces to end users as the opaque
`Unexpected error in Claude SDK`. Reproduced on 0.1.81 and 0.2.82.

Fix: `list(... or [])`. An empty list is the natural "no
restriction" sentinel and is already handled correctly downstream
(line 256 only emits `--allowedTools` when the list is truthy).

Added a regression test that fails before the patch and passes after.
Copy link
Copy Markdown

@kiwigitops kiwigitops left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick clarifying question on the semantics when options.skills is also set. With this change, if a caller passes allowed_tools=None (meaning "no restriction") AND e.g. skills="all" or skills=["foo"], then _apply_skills_defaults coerces None[] and appends "Skill" / "Skill(foo)" to that empty list. The resulting effective_allowed_tools is non-empty, so the truthy check at line 256 fires and --allowedTools Skill (or Skill(foo)) is passed to the CLI — which is a restrictive allowlist of one tool rather than "no restriction."

Is that the intended interaction for the claude-code-telegram use case (where the wrapper sets DISABLE_TOOL_VALIDATION=true and presumably wants all tools available, including skills), or would callers passing None typically not be using skills= at the same time? If the latter, it might be worth a one-line comment in _apply_skills_defaults noting that the or [] path produces a restrictive allowlist when skills is set, so future readers don't read None-means-"no restriction" too literally. Happy to be told this is out of scope for a one-line defensive guard.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants