Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 27 additions & 16 deletions src/claude_agent_sdk/_internal/transport/subprocess_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,11 @@ def _build_settings_value(self) -> str | None:
- A JSON string (if sandbox is provided or settings is JSON)
- A file path (if only settings path is provided without sandbox)
- None if neither settings nor sandbox is provided

Raises:
- ValueError: If ``settings`` is an inline JSON string that fails to
parse, or a settings file that exists but contains invalid JSON.
A missing settings file is only logged (documented degradation).
"""
has_settings = self._options.settings is not None
has_sandbox = self._options.sandbox is not None
Expand All @@ -494,27 +499,33 @@ def _build_settings_value(self) -> str | None:
if has_settings:
assert self._options.settings is not None
settings_str = self._options.settings.strip()
# Check if settings is a JSON string or a file path
if settings_str.startswith("{") and settings_str.endswith("}"):
# Parse JSON string
# Check if settings is a JSON string or a file path. Any value
# starting with "{" is treated as inline JSON and parsed
# strictly: also requiring the closing brace would let a
# truncated string (e.g. '{model:sonnet') fall through to the
# file-path branch and be silently dropped as "file not found".
if settings_str.startswith("{"):
# Inline JSON string: parse strictly. A malformed inline
# string must fail loudly -- silently dropping it would
# discard the caller's entire settings object (model,
# permissions, env, hooks) while the run looks healthy.
try:
settings_obj = json.loads(settings_str)
except json.JSONDecodeError:
# If parsing fails, treat as file path
logger.warning(
f"Failed to parse settings as JSON, treating as file path: {settings_str}"
)
# Read the file
settings_path = Path(settings_str)
if settings_path.exists():
with settings_path.open(encoding="utf-8") as f:
settings_obj = json.load(f)
except json.JSONDecodeError as e:
raise ValueError(
f"Invalid JSON in inline settings string: {e}"
) from e
else:
# It's a file path - read and parse
settings_path = Path(settings_str)
if settings_path.exists():
with settings_path.open(encoding="utf-8") as f:
settings_obj = json.load(f)
try:
with settings_path.open(encoding="utf-8") as f:
settings_obj = json.load(f)
except json.JSONDecodeError as e:
raise ValueError(
f"Invalid JSON in settings file {settings_path}: {e}"
) from e
else:
logger.warning(f"Settings file not found: {settings_path}")

Expand Down Expand Up @@ -809,8 +820,8 @@ async def connect(self) -> None:
if not os.environ.get("CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK"):
await self._check_claude_version()

cmd = self._build_command()
try:
cmd = self._build_command()
# Merge environment variables. CLAUDE_CODE_ENTRYPOINT defaults to
# sdk-py regardless of inherited process env; options.env can override
# it. CLAUDE_AGENT_SDK_VERSION is always set by the SDK.
Expand Down
5 changes: 3 additions & 2 deletions src/claude_agent_sdk/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2108,8 +2108,9 @@ class ClaudeAgentOptions:
Without ``sandbox``, the value is passed as-is to the ``--settings`` CLI
flag. When ``sandbox`` is also set, the settings are merged with the sandbox
settings and passed as one JSON string: an inline string is parsed
directly, and a path is read from disk (a missing file is logged and only
the sandbox settings are passed).
directly (malformed JSON raises an error), and a path is read from disk
(a missing file is logged and only the sandbox settings are passed;
a file containing malformed JSON raises an error naming the file).

These are loaded into the "flag settings" layer, which has the highest
priority among user-controlled settings.
Expand Down
124 changes: 124 additions & 0 deletions tests/test_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -1894,6 +1894,130 @@ def test_build_command_with_settings_file_and_no_sandbox(self):
settings_idx = cmd.index("--settings")
assert cmd[settings_idx + 1] == "/path/to/settings.json"

def test_malformed_inline_settings_with_sandbox_raises(self, caplog):
"""Malformed inline JSON + sandbox must raise, not silently drop settings.

Regression test for https://github.com/anthropics/claude-agent-sdk-python/issues/1335:
one trailing comma used to cost the entire settings object while the
run looked healthy (only sandbox survived), accompanied by a
misleading "treating as file path" warning naming the JSON text.
"""
import logging

malformed = '{"model":"sonnet",}'
sandbox = {"enabled": True}
transport = SubprocessCLITransport(
prompt="test",
options=make_options(settings=malformed, sandbox=sandbox),
)

with (
caplog.at_level(logging.WARNING),
pytest.raises(ValueError, match="inline settings string"),
):
transport._build_settings_value()

# The old misleading warning named the JSON text as a "file path";
# it must be gone -- the error now says what actually happened.
assert "treating as file path" not in caplog.text

def test_malformed_inline_settings_missing_brace_raises(self, caplog):
"""Inline JSON missing the closing brace must raise, not be treated as a path.

The discriminator used to require both braces, so a truncated string
like '{model:sonnet' fell into the file-path branch and was silently
dropped with a misleading "Settings file not found" warning.
"""
import logging

malformed = "{model:sonnet"
sandbox = {"enabled": True}
transport = SubprocessCLITransport(
prompt="test",
options=make_options(settings=malformed, sandbox=sandbox),
)

with (
caplog.at_level(logging.WARNING),
pytest.raises(ValueError, match="inline settings string"),
):
transport._build_settings_value()

assert "Settings file not found" not in caplog.text

def test_malformed_settings_file_with_sandbox_raises_naming_file(self, tmp_path):
"""Malformed settings file + sandbox must raise an error naming the file."""
bad_file = tmp_path / "settings.json"
bad_file.write_text('{"model":"sonnet",}', encoding="utf-8")
sandbox = {"enabled": True}
transport = SubprocessCLITransport(
prompt="test",
options=make_options(settings=str(bad_file), sandbox=sandbox),
)

with pytest.raises(ValueError, match="settings file") as exc_info:
transport._build_settings_value()
assert str(bad_file) in str(exc_info.value)

def test_missing_settings_file_with_sandbox_degrades(self, caplog):
"""Nonexistent settings path + sandbox keeps the documented degradation."""
import json
import logging

sandbox = {"enabled": True}
transport = SubprocessCLITransport(
prompt="test",
options=make_options(
settings="/nonexistent/settings.json", sandbox=sandbox
),
)

with caplog.at_level(logging.WARNING):
value = transport._build_settings_value()

# Only sandbox survives, and the missing file is logged (not raised).
assert json.loads(value) == {"sandbox": sandbox}
assert "Settings file not found" in caplog.text

def test_malformed_inline_settings_without_sandbox_passes_through(self):
"""Without sandbox, settings are passed to the CLI verbatim (it rejects)."""
malformed = '{"model":"sonnet",}'
transport = SubprocessCLITransport(
prompt="test",
options=make_options(settings=malformed),
)

assert transport._build_settings_value() == malformed

def test_malformed_inline_settings_connect_wraps_error(self):
"""connect() wraps settings errors in CLIConnectionError and sets _exit_error."""
from unittest.mock import patch

from claude_agent_sdk._errors import ClaudeSDKError, CLIConnectionError

malformed = '{"model":"sonnet",}'
sandbox = {"enabled": True}
transport = SubprocessCLITransport(
prompt="test",
options=make_options(settings=malformed, sandbox=sandbox),
)
# Skip CLI discovery and the version probe; _build_command() is what
# must raise here.
transport._cli_path = "/usr/bin/claude"

async def _test():
with (
patch.dict(os.environ, {"CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK": "1"}),
pytest.raises(CLIConnectionError, match="inline settings"),
):
await transport.connect()

# The failure is recorded as a proper SDK error, not a bare
# JSONDecodeError escaping the hierarchy.
assert isinstance(transport._exit_error, ClaudeSDKError)

anyio.run(_test)

def test_build_command_sandbox_minimal(self):
"""Test sandbox with minimal configuration."""
import json
Expand Down