diff --git a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py index 58abc438d..6fbdceb95 100644 --- a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -510,9 +510,17 @@ def _build_settings_value(self) -> str | None: else: logger.warning(f"Settings file not found: {settings_path}") - # Merge sandbox settings + # Merge sandbox settings. The CLI reads sandbox.failIfUnavailable as + # `?? false`, so an enabled sandbox that cannot start degrades to a + # warning and every command then runs unsandboxed. Default it to true + # for an enabled sandbox, matching the TypeScript SDK, so callers who + # asked for a sandbox fail loudly instead of silently losing it. if has_sandbox: - settings_obj["sandbox"] = self._options.sandbox + assert self._options.sandbox is not None + sandbox: dict[str, Any] = dict(self._options.sandbox) + if sandbox.get("enabled") is True and "failIfUnavailable" not in sandbox: + sandbox["failIfUnavailable"] = True + settings_obj["sandbox"] = sandbox return json.dumps(settings_obj) diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index b0f5ce5d5..803c42636 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -888,6 +888,10 @@ class SandboxSettings(TypedDict, total=False): Attributes: enabled: Enable bash sandboxing (macOS/Linux only). Default: False + failIfUnavailable: Exit with an error at startup if ``enabled`` is True but the + sandbox cannot start. When False the CLI only warns and commands run + unsandboxed. The SDK defaults this to True for an enabled sandbox, + matching the TypeScript SDK. autoAllowBashIfSandboxed: Auto-approve bash commands when sandboxed. Default: True excludedCommands: Commands that should run outside the sandbox (e.g., ["git", "docker"]) allowUnsandboxedCommands: Allow commands to bypass sandbox via dangerouslyDisableSandbox. @@ -912,6 +916,7 @@ class SandboxSettings(TypedDict, total=False): """ enabled: bool + failIfUnavailable: bool autoAllowBashIfSandboxed: bool excludedCommands: list[str] allowUnsandboxedCommands: bool diff --git a/tests/test_transport.py b/tests/test_transport.py index 2a0ed35bf..a5bb19178 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -1841,7 +1841,86 @@ def test_build_command_sandbox_minimal(self): settings_value = cmd[settings_idx + 1] parsed = json.loads(settings_value) - assert parsed == {"sandbox": {"enabled": True}} + assert parsed == {"sandbox": {"enabled": True, "failIfUnavailable": True}} + + def test_sandbox_enabled_defaults_to_fail_closed(self): + """An enabled sandbox defaults failIfUnavailable to True. + + The CLI reads this setting as ``?? false``, so without it a sandbox + that cannot start only warns and every command runs unsandboxed. The + TypeScript SDK injects True here; the Python SDK must match so the + same options do not silently fail open. + """ + import json + + from claude_agent_sdk import SandboxSettings + + sandbox: SandboxSettings = {"enabled": True, "excludedCommands": ["git"]} + + transport = SubprocessCLITransport( + prompt="test", + options=make_options(sandbox=sandbox), + ) + + cmd = transport._build_command() + parsed = json.loads(cmd[cmd.index("--settings") + 1]) + + assert parsed["sandbox"]["failIfUnavailable"] is True + assert parsed["sandbox"]["excludedCommands"] == ["git"] + + def test_sandbox_explicit_fail_if_unavailable_is_preserved(self): + """An explicit failIfUnavailable is never overridden.""" + import json + + from claude_agent_sdk import SandboxSettings + + sandbox: SandboxSettings = {"enabled": True, "failIfUnavailable": False} + + transport = SubprocessCLITransport( + prompt="test", + options=make_options(sandbox=sandbox), + ) + + cmd = transport._build_command() + parsed = json.loads(cmd[cmd.index("--settings") + 1]) + + assert parsed["sandbox"]["failIfUnavailable"] is False + + def test_sandbox_not_enabled_gets_no_fail_if_unavailable(self): + """A sandbox that is not enabled is passed through untouched.""" + import json + + from claude_agent_sdk import SandboxSettings + + sandbox: SandboxSettings = {"enabled": False} + + transport = SubprocessCLITransport( + prompt="test", + options=make_options(sandbox=sandbox), + ) + + cmd = transport._build_command() + parsed = json.loads(cmd[cmd.index("--settings") + 1]) + + assert parsed == {"sandbox": {"enabled": False}} + + def test_sandbox_options_dict_is_not_mutated(self): + """Building the command does not mutate the caller's sandbox dict.""" + import json + + from claude_agent_sdk import SandboxSettings + + sandbox: SandboxSettings = {"enabled": True} + + transport = SubprocessCLITransport( + prompt="test", + options=make_options(sandbox=sandbox), + ) + + cmd = transport._build_command() + json.loads(cmd[cmd.index("--settings") + 1]) + + assert sandbox == {"enabled": True} def test_sandbox_network_config(self): """Test sandbox with full network configuration."""