Skip to content
Merged
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
7 changes: 7 additions & 0 deletions src/claude_agent_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
PreCompactHookInput,
PreToolUseHookInput,
ResultMessage,
SandboxIgnoreViolations,
SandboxNetworkConfig,
SandboxSettings,
SdkPluginConfig,
SettingSource,
StopHookInput,
Expand Down Expand Up @@ -342,6 +345,10 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
"SettingSource",
# Plugin support
"SdkPluginConfig",
# Sandbox support
"SandboxSettings",
"SandboxNetworkConfig",
"SandboxIgnoreViolations",
# MCP Server Support
"create_sdk_mcp_server",
"tool",
Expand Down
60 changes: 58 additions & 2 deletions src/claude_agent_sdk/_internal/transport/subprocess_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,60 @@ def _find_bundled_cli(self) -> str | None:

return None

def _build_settings_value(self) -> str | None:
"""Build settings value, merging sandbox settings if provided.

Returns the settings value as either:
- 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
"""
has_settings = self._options.settings is not None
has_sandbox = self._options.sandbox is not None

if not has_settings and not has_sandbox:
return None

# If only settings path and no sandbox, pass through as-is
if has_settings and not has_sandbox:
return self._options.settings

# If we have sandbox settings, we need to merge into a JSON object
settings_obj: dict[str, Any] = {}

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
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)
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)
else:
logger.warning(f"Settings file not found: {settings_path}")

# Merge sandbox settings
if has_sandbox:
settings_obj["sandbox"] = self._options.sandbox

return json.dumps(settings_obj)

def _build_command(self) -> list[str]:
"""Build CLI command with arguments."""
cmd = [self._cli_path, "--output-format", "stream-json", "--verbose"]
Expand Down Expand Up @@ -163,8 +217,10 @@ def _build_command(self) -> list[str]:
if self._options.resume:
cmd.extend(["--resume", self._options.resume])

if self._options.settings:
cmd.extend(["--settings", self._options.settings])
# Handle settings and sandbox: merge sandbox into settings if both are provided
settings_value = self._build_settings_value()
if settings_value:
cmd.extend(["--settings", settings_value])

if self._options.add_dirs:
# Convert all paths to strings and add each directory
Expand Down
81 changes: 81 additions & 0 deletions src/claude_agent_sdk/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,83 @@ class SdkPluginConfig(TypedDict):
path: str


# Sandbox configuration types
class SandboxNetworkConfig(TypedDict, total=False):
"""Network configuration for sandbox.

Attributes:
allowUnixSockets: Unix socket paths accessible in sandbox (e.g., SSH agents).
allowAllUnixSockets: Allow all Unix sockets (less secure).
allowLocalBinding: Allow binding to localhost ports (macOS only).
httpProxyPort: HTTP proxy port if bringing your own proxy.
socksProxyPort: SOCKS5 proxy port if bringing your own proxy.
"""

allowUnixSockets: list[str]
allowAllUnixSockets: bool
allowLocalBinding: bool
httpProxyPort: int
socksProxyPort: int


class SandboxIgnoreViolations(TypedDict, total=False):
"""Violations to ignore in sandbox.

Attributes:
file: File paths for which violations should be ignored.
network: Network hosts for which violations should be ignored.
"""

file: list[str]
network: list[str]


class SandboxSettings(TypedDict, total=False):
"""Sandbox settings configuration.

This controls how Claude Code sandboxes bash commands for filesystem
and network isolation.

**Important:** Filesystem and network restrictions are configured via permission
rules, not via these sandbox settings:
- Filesystem read restrictions: Use Read deny rules
- Filesystem write restrictions: Use Edit allow/deny rules
- Network restrictions: Use WebFetch allow/deny rules

Attributes:
enabled: Enable bash sandboxing (macOS/Linux only). Default: False
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.
When False, all commands must run sandboxed (or be in excludedCommands). Default: True
network: Network configuration for sandbox.
ignoreViolations: Violations to ignore.
enableWeakerNestedSandbox: Enable weaker sandbox for unprivileged Docker environments
(Linux only). Reduces security. Default: False

Example:
```python
sandbox_settings: SandboxSettings = {
"enabled": True,
"autoAllowBashIfSandboxed": True,
"excludedCommands": ["docker"],
"network": {
"allowUnixSockets": ["/var/run/docker.sock"],
"allowLocalBinding": True
}
}
```
"""

enabled: bool
autoAllowBashIfSandboxed: bool
excludedCommands: list[str]
allowUnsandboxedCommands: bool
network: SandboxNetworkConfig
ignoreViolations: SandboxIgnoreViolations
enableWeakerNestedSandbox: bool


# Content block types
@dataclass
class TextBlock:
Expand Down Expand Up @@ -569,6 +646,10 @@ class ClaudeAgentOptions:
agents: dict[str, AgentDefinition] | None = None
# Setting sources to load (user, project, local)
setting_sources: list[SettingSource] | None = None
# Sandbox configuration for bash command isolation.
# Filesystem and network restrictions are derived from permission rules (Read/Edit/WebFetch),
# not from these sandbox settings.
sandbox: SandboxSettings | None = None
# Plugin configurations for custom plugins
plugins: list[SdkPluginConfig] = field(default_factory=list)
# Max tokens for thinking blocks
Expand Down
147 changes: 147 additions & 0 deletions tests/test_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,3 +500,150 @@ async def _test():
assert user_passed == "claude"

anyio.run(_test)

def test_build_command_with_sandbox_only(self):
"""Test building CLI command with sandbox settings (no existing settings)."""
import json

from claude_agent_sdk import SandboxSettings

sandbox: SandboxSettings = {
"enabled": True,
"autoAllowBashIfSandboxed": True,
"network": {
"allowLocalBinding": True,
"allowUnixSockets": ["/var/run/docker.sock"],
},
}

transport = SubprocessCLITransport(
prompt="test",
options=make_options(sandbox=sandbox),
)

cmd = transport._build_command()

# Should have --settings with sandbox merged in
assert "--settings" in cmd
settings_idx = cmd.index("--settings")
settings_value = cmd[settings_idx + 1]

# Parse and verify
parsed = json.loads(settings_value)
assert "sandbox" in parsed
assert parsed["sandbox"]["enabled"] is True
assert parsed["sandbox"]["autoAllowBashIfSandboxed"] is True
assert parsed["sandbox"]["network"]["allowLocalBinding"] is True
assert parsed["sandbox"]["network"]["allowUnixSockets"] == [
"/var/run/docker.sock"
]

def test_build_command_with_sandbox_and_settings_json(self):
"""Test building CLI command with sandbox merged into existing settings JSON."""
import json

from claude_agent_sdk import SandboxSettings

# Existing settings as JSON string
existing_settings = (
'{"permissions": {"allow": ["Bash(ls:*)"]}, "verbose": true}'
)

sandbox: SandboxSettings = {
"enabled": True,
"excludedCommands": ["git", "docker"],
}

transport = SubprocessCLITransport(
prompt="test",
options=make_options(settings=existing_settings, sandbox=sandbox),
)

cmd = transport._build_command()

# Should have merged settings
assert "--settings" in cmd
settings_idx = cmd.index("--settings")
settings_value = cmd[settings_idx + 1]

parsed = json.loads(settings_value)

# Original settings should be preserved
assert parsed["permissions"] == {"allow": ["Bash(ls:*)"]}
assert parsed["verbose"] is True

# Sandbox should be merged in
assert "sandbox" in parsed
assert parsed["sandbox"]["enabled"] is True
assert parsed["sandbox"]["excludedCommands"] == ["git", "docker"]

def test_build_command_with_settings_file_and_no_sandbox(self):
"""Test that settings file path is passed through when no sandbox."""
transport = SubprocessCLITransport(
prompt="test",
options=make_options(settings="/path/to/settings.json"),
)

cmd = transport._build_command()

# Should pass path directly, not parse it
assert "--settings" in cmd
settings_idx = cmd.index("--settings")
assert cmd[settings_idx + 1] == "/path/to/settings.json"

def test_build_command_sandbox_minimal(self):
"""Test sandbox with minimal configuration."""
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()

assert "--settings" in cmd
settings_idx = cmd.index("--settings")
settings_value = cmd[settings_idx + 1]

parsed = json.loads(settings_value)
assert parsed == {"sandbox": {"enabled": True}}

def test_sandbox_network_config(self):
"""Test sandbox with full network configuration."""
import json

from claude_agent_sdk import SandboxSettings

sandbox: SandboxSettings = {
"enabled": True,
"network": {
"allowUnixSockets": ["/tmp/ssh-agent.sock"],
"allowAllUnixSockets": False,
"allowLocalBinding": True,
"httpProxyPort": 8080,
"socksProxyPort": 8081,
},
}

transport = SubprocessCLITransport(
prompt="test",
options=make_options(sandbox=sandbox),
)

cmd = transport._build_command()
settings_idx = cmd.index("--settings")
settings_value = cmd[settings_idx + 1]

parsed = json.loads(settings_value)
network = parsed["sandbox"]["network"]

assert network["allowUnixSockets"] == ["/tmp/ssh-agent.sock"]
assert network["allowAllUnixSockets"] is False
assert network["allowLocalBinding"] is True
assert network["httpProxyPort"] == 8080
assert network["socksProxyPort"] == 8081