Skip to content

Commit f21f63e

Browse files
Create sandbox adapter interface for Python SDK (anthropics#363)
Adds programmatic sandbox configuration to the Python SDK, matching the TypeScript SDK's approach. Changes: - Add SandboxSettings, SandboxNetworkConfig, SandboxIgnoreViolations types - Add sandbox field to ClaudeAgentOptions - Merge sandbox into --settings CLI flag in SubprocessCLITransport - Export sandbox types from package __init__.py - Add comprehensive tests for sandbox settings **Important:** Filesystem and network restrictions are configured via permission rules (Read/Edit/WebFetch), not via these sandbox settings. The sandbox settings control sandbox behavior (enabled, auto-allow, excluded commands, etc.). Example usage: ```python from claude_agent_sdk import query, SandboxSettings result = query( prompt='Build and test the project', options=ClaudeAgentOptions( sandbox={ 'enabled': True, 'autoAllowBashIfSandboxed': True, 'excludedCommands': ['docker'], 'network': { 'allowLocalBinding': True, 'allowUnixSockets': ['/var/run/docker.sock'] } } ) ) ``` Co-authored-by: Claude <[email protected]>
1 parent d553184 commit f21f63e

File tree

4 files changed

+293
-2
lines changed

4 files changed

+293
-2
lines changed

src/claude_agent_sdk/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@
3939
PreCompactHookInput,
4040
PreToolUseHookInput,
4141
ResultMessage,
42+
SandboxIgnoreViolations,
43+
SandboxNetworkConfig,
44+
SandboxSettings,
4245
SdkPluginConfig,
4346
SettingSource,
4447
StopHookInput,
@@ -342,6 +345,10 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
342345
"SettingSource",
343346
# Plugin support
344347
"SdkPluginConfig",
348+
# Sandbox support
349+
"SandboxSettings",
350+
"SandboxNetworkConfig",
351+
"SandboxIgnoreViolations",
345352
# MCP Server Support
346353
"create_sdk_mcp_server",
347354
"tool",

src/claude_agent_sdk/_internal/transport/subprocess_cli.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,60 @@ def _find_bundled_cli(self) -> str | None:
114114

115115
return None
116116

117+
def _build_settings_value(self) -> str | None:
118+
"""Build settings value, merging sandbox settings if provided.
119+
120+
Returns the settings value as either:
121+
- A JSON string (if sandbox is provided or settings is JSON)
122+
- A file path (if only settings path is provided without sandbox)
123+
- None if neither settings nor sandbox is provided
124+
"""
125+
has_settings = self._options.settings is not None
126+
has_sandbox = self._options.sandbox is not None
127+
128+
if not has_settings and not has_sandbox:
129+
return None
130+
131+
# If only settings path and no sandbox, pass through as-is
132+
if has_settings and not has_sandbox:
133+
return self._options.settings
134+
135+
# If we have sandbox settings, we need to merge into a JSON object
136+
settings_obj: dict[str, Any] = {}
137+
138+
if has_settings:
139+
assert self._options.settings is not None
140+
settings_str = self._options.settings.strip()
141+
# Check if settings is a JSON string or a file path
142+
if settings_str.startswith("{") and settings_str.endswith("}"):
143+
# Parse JSON string
144+
try:
145+
settings_obj = json.loads(settings_str)
146+
except json.JSONDecodeError:
147+
# If parsing fails, treat as file path
148+
logger.warning(
149+
f"Failed to parse settings as JSON, treating as file path: {settings_str}"
150+
)
151+
# Read the file
152+
settings_path = Path(settings_str)
153+
if settings_path.exists():
154+
with settings_path.open(encoding="utf-8") as f:
155+
settings_obj = json.load(f)
156+
else:
157+
# It's a file path - read and parse
158+
settings_path = Path(settings_str)
159+
if settings_path.exists():
160+
with settings_path.open(encoding="utf-8") as f:
161+
settings_obj = json.load(f)
162+
else:
163+
logger.warning(f"Settings file not found: {settings_path}")
164+
165+
# Merge sandbox settings
166+
if has_sandbox:
167+
settings_obj["sandbox"] = self._options.sandbox
168+
169+
return json.dumps(settings_obj)
170+
117171
def _build_command(self) -> list[str]:
118172
"""Build CLI command with arguments."""
119173
cmd = [self._cli_path, "--output-format", "stream-json", "--verbose"]
@@ -163,8 +217,10 @@ def _build_command(self) -> list[str]:
163217
if self._options.resume:
164218
cmd.extend(["--resume", self._options.resume])
165219

166-
if self._options.settings:
167-
cmd.extend(["--settings", self._options.settings])
220+
# Handle settings and sandbox: merge sandbox into settings if both are provided
221+
settings_value = self._build_settings_value()
222+
if settings_value:
223+
cmd.extend(["--settings", settings_value])
168224

169225
if self._options.add_dirs:
170226
# Convert all paths to strings and add each directory

src/claude_agent_sdk/types.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,83 @@ class SdkPluginConfig(TypedDict):
419419
path: str
420420

421421

422+
# Sandbox configuration types
423+
class SandboxNetworkConfig(TypedDict, total=False):
424+
"""Network configuration for sandbox.
425+
426+
Attributes:
427+
allowUnixSockets: Unix socket paths accessible in sandbox (e.g., SSH agents).
428+
allowAllUnixSockets: Allow all Unix sockets (less secure).
429+
allowLocalBinding: Allow binding to localhost ports (macOS only).
430+
httpProxyPort: HTTP proxy port if bringing your own proxy.
431+
socksProxyPort: SOCKS5 proxy port if bringing your own proxy.
432+
"""
433+
434+
allowUnixSockets: list[str]
435+
allowAllUnixSockets: bool
436+
allowLocalBinding: bool
437+
httpProxyPort: int
438+
socksProxyPort: int
439+
440+
441+
class SandboxIgnoreViolations(TypedDict, total=False):
442+
"""Violations to ignore in sandbox.
443+
444+
Attributes:
445+
file: File paths for which violations should be ignored.
446+
network: Network hosts for which violations should be ignored.
447+
"""
448+
449+
file: list[str]
450+
network: list[str]
451+
452+
453+
class SandboxSettings(TypedDict, total=False):
454+
"""Sandbox settings configuration.
455+
456+
This controls how Claude Code sandboxes bash commands for filesystem
457+
and network isolation.
458+
459+
**Important:** Filesystem and network restrictions are configured via permission
460+
rules, not via these sandbox settings:
461+
- Filesystem read restrictions: Use Read deny rules
462+
- Filesystem write restrictions: Use Edit allow/deny rules
463+
- Network restrictions: Use WebFetch allow/deny rules
464+
465+
Attributes:
466+
enabled: Enable bash sandboxing (macOS/Linux only). Default: False
467+
autoAllowBashIfSandboxed: Auto-approve bash commands when sandboxed. Default: True
468+
excludedCommands: Commands that should run outside the sandbox (e.g., ["git", "docker"])
469+
allowUnsandboxedCommands: Allow commands to bypass sandbox via dangerouslyDisableSandbox.
470+
When False, all commands must run sandboxed (or be in excludedCommands). Default: True
471+
network: Network configuration for sandbox.
472+
ignoreViolations: Violations to ignore.
473+
enableWeakerNestedSandbox: Enable weaker sandbox for unprivileged Docker environments
474+
(Linux only). Reduces security. Default: False
475+
476+
Example:
477+
```python
478+
sandbox_settings: SandboxSettings = {
479+
"enabled": True,
480+
"autoAllowBashIfSandboxed": True,
481+
"excludedCommands": ["docker"],
482+
"network": {
483+
"allowUnixSockets": ["/var/run/docker.sock"],
484+
"allowLocalBinding": True
485+
}
486+
}
487+
```
488+
"""
489+
490+
enabled: bool
491+
autoAllowBashIfSandboxed: bool
492+
excludedCommands: list[str]
493+
allowUnsandboxedCommands: bool
494+
network: SandboxNetworkConfig
495+
ignoreViolations: SandboxIgnoreViolations
496+
enableWeakerNestedSandbox: bool
497+
498+
422499
# Content block types
423500
@dataclass
424501
class TextBlock:
@@ -569,6 +646,10 @@ class ClaudeAgentOptions:
569646
agents: dict[str, AgentDefinition] | None = None
570647
# Setting sources to load (user, project, local)
571648
setting_sources: list[SettingSource] | None = None
649+
# Sandbox configuration for bash command isolation.
650+
# Filesystem and network restrictions are derived from permission rules (Read/Edit/WebFetch),
651+
# not from these sandbox settings.
652+
sandbox: SandboxSettings | None = None
572653
# Plugin configurations for custom plugins
573654
plugins: list[SdkPluginConfig] = field(default_factory=list)
574655
# Max tokens for thinking blocks

tests/test_transport.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,3 +500,150 @@ async def _test():
500500
assert user_passed == "claude"
501501

502502
anyio.run(_test)
503+
504+
def test_build_command_with_sandbox_only(self):
505+
"""Test building CLI command with sandbox settings (no existing settings)."""
506+
import json
507+
508+
from claude_agent_sdk import SandboxSettings
509+
510+
sandbox: SandboxSettings = {
511+
"enabled": True,
512+
"autoAllowBashIfSandboxed": True,
513+
"network": {
514+
"allowLocalBinding": True,
515+
"allowUnixSockets": ["/var/run/docker.sock"],
516+
},
517+
}
518+
519+
transport = SubprocessCLITransport(
520+
prompt="test",
521+
options=make_options(sandbox=sandbox),
522+
)
523+
524+
cmd = transport._build_command()
525+
526+
# Should have --settings with sandbox merged in
527+
assert "--settings" in cmd
528+
settings_idx = cmd.index("--settings")
529+
settings_value = cmd[settings_idx + 1]
530+
531+
# Parse and verify
532+
parsed = json.loads(settings_value)
533+
assert "sandbox" in parsed
534+
assert parsed["sandbox"]["enabled"] is True
535+
assert parsed["sandbox"]["autoAllowBashIfSandboxed"] is True
536+
assert parsed["sandbox"]["network"]["allowLocalBinding"] is True
537+
assert parsed["sandbox"]["network"]["allowUnixSockets"] == [
538+
"/var/run/docker.sock"
539+
]
540+
541+
def test_build_command_with_sandbox_and_settings_json(self):
542+
"""Test building CLI command with sandbox merged into existing settings JSON."""
543+
import json
544+
545+
from claude_agent_sdk import SandboxSettings
546+
547+
# Existing settings as JSON string
548+
existing_settings = (
549+
'{"permissions": {"allow": ["Bash(ls:*)"]}, "verbose": true}'
550+
)
551+
552+
sandbox: SandboxSettings = {
553+
"enabled": True,
554+
"excludedCommands": ["git", "docker"],
555+
}
556+
557+
transport = SubprocessCLITransport(
558+
prompt="test",
559+
options=make_options(settings=existing_settings, sandbox=sandbox),
560+
)
561+
562+
cmd = transport._build_command()
563+
564+
# Should have merged settings
565+
assert "--settings" in cmd
566+
settings_idx = cmd.index("--settings")
567+
settings_value = cmd[settings_idx + 1]
568+
569+
parsed = json.loads(settings_value)
570+
571+
# Original settings should be preserved
572+
assert parsed["permissions"] == {"allow": ["Bash(ls:*)"]}
573+
assert parsed["verbose"] is True
574+
575+
# Sandbox should be merged in
576+
assert "sandbox" in parsed
577+
assert parsed["sandbox"]["enabled"] is True
578+
assert parsed["sandbox"]["excludedCommands"] == ["git", "docker"]
579+
580+
def test_build_command_with_settings_file_and_no_sandbox(self):
581+
"""Test that settings file path is passed through when no sandbox."""
582+
transport = SubprocessCLITransport(
583+
prompt="test",
584+
options=make_options(settings="/path/to/settings.json"),
585+
)
586+
587+
cmd = transport._build_command()
588+
589+
# Should pass path directly, not parse it
590+
assert "--settings" in cmd
591+
settings_idx = cmd.index("--settings")
592+
assert cmd[settings_idx + 1] == "/path/to/settings.json"
593+
594+
def test_build_command_sandbox_minimal(self):
595+
"""Test sandbox with minimal configuration."""
596+
import json
597+
598+
from claude_agent_sdk import SandboxSettings
599+
600+
sandbox: SandboxSettings = {"enabled": True}
601+
602+
transport = SubprocessCLITransport(
603+
prompt="test",
604+
options=make_options(sandbox=sandbox),
605+
)
606+
607+
cmd = transport._build_command()
608+
609+
assert "--settings" in cmd
610+
settings_idx = cmd.index("--settings")
611+
settings_value = cmd[settings_idx + 1]
612+
613+
parsed = json.loads(settings_value)
614+
assert parsed == {"sandbox": {"enabled": True}}
615+
616+
def test_sandbox_network_config(self):
617+
"""Test sandbox with full network configuration."""
618+
import json
619+
620+
from claude_agent_sdk import SandboxSettings
621+
622+
sandbox: SandboxSettings = {
623+
"enabled": True,
624+
"network": {
625+
"allowUnixSockets": ["/tmp/ssh-agent.sock"],
626+
"allowAllUnixSockets": False,
627+
"allowLocalBinding": True,
628+
"httpProxyPort": 8080,
629+
"socksProxyPort": 8081,
630+
},
631+
}
632+
633+
transport = SubprocessCLITransport(
634+
prompt="test",
635+
options=make_options(sandbox=sandbox),
636+
)
637+
638+
cmd = transport._build_command()
639+
settings_idx = cmd.index("--settings")
640+
settings_value = cmd[settings_idx + 1]
641+
642+
parsed = json.loads(settings_value)
643+
network = parsed["sandbox"]["network"]
644+
645+
assert network["allowUnixSockets"] == ["/tmp/ssh-agent.sock"]
646+
assert network["allowAllUnixSockets"] is False
647+
assert network["allowLocalBinding"] is True
648+
assert network["httpProxyPort"] == 8080
649+
assert network["socksProxyPort"] == 8081

0 commit comments

Comments
 (0)