From 261c14eb45953991fef73684fea4a71ea49a7fb8 Mon Sep 17 00:00:00 2001 From: Lex Oleksiienko Date: Fri, 15 May 2026 21:51:36 -0600 Subject: [PATCH] fix: guard against None allowed_tools in _apply_skills_defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .../_internal/transport/subprocess_cli.py | 2 +- tests/test_transport.py | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py index 833cba4c..fda5f5bd 100644 --- a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -193,7 +193,7 @@ def _apply_skills_defaults( Does not mutate the original options object. """ - allowed_tools: list[str] = list(self._options.allowed_tools) + allowed_tools: list[str] = list(self._options.allowed_tools or []) setting_sources: list[str] | None = ( list(self._options.setting_sources) if self._options.setting_sources is not None diff --git a/tests/test_transport.py b/tests/test_transport.py index 1e61e9ad..5a1fdcd8 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -101,6 +101,31 @@ def test_build_command_strict_mcp_config(self): transport = SubprocessCLITransport(prompt="test", options=make_options()) assert "--strict-mcp-config" not in transport._build_command() + def test_build_command_allowed_tools_none(self): + """Regression: passing ``allowed_tools=None`` must not crash. + + The type annotation declares ``list[str]`` with a default factory of + ``list``, but Python's runtime does not enforce TypedDict / dataclass + type hints — callers (notably wrapper libraries like + ``claude-code-telegram``) sometimes set the attribute to ``None`` after + construction to express "no restriction". Previously + ``_apply_skills_defaults`` called ``list(options.allowed_tools)`` + unconditionally and raised ``TypeError: 'NoneType' object is not + iterable`` before the CLI even started, surfacing as + ``"Unexpected error in Claude SDK"`` for the end user. + + The defensive fix is ``list(... or [])`` — equivalent to an empty + allowlist, which the CLI treats as "no ``--allowedTools`` flag". + """ + options = make_options() + # Simulate downstream code that assigns None after construction. + options.allowed_tools = None # type: ignore[assignment] + transport = SubprocessCLITransport(prompt="test", options=options) + + # Must not raise. + cmd = transport._build_command() + assert "--allowedTools" not in cmd + def test_cli_path_accepts_pathlib_path(self): """Test that cli_path accepts pathlib.Path objects.""" from pathlib import Path