diff --git a/docs/adapters/claude_sdk.md b/docs/adapters/claude_sdk.md index a985b7ee6..9ee2d55e8 100644 --- a/docs/adapters/claude_sdk.md +++ b/docs/adapters/claude_sdk.md @@ -30,3 +30,24 @@ Runnable scripts: [examples/claude_sdk/](../../examples/claude_sdk/). When the account or model can't run `"auto"`, the CLI starts the session in `"default"`, and the adapter logs a warning. +- **CLI launch options never override the adapter's own wiring.** + `cli=ClaudeCLIOptions(...)` sets the executable, plugin folders, extra + directories, env and extra flags, but cannot replace the Band MCP server, the + tool allowlist or `setting_sources`: an `extra_args` flag in + `RESERVED_CLI_FLAGS` raises `ValueError`, as does a dash-prefixed key. That + covers every flag the adapter sets (use its typed option instead) plus flags + such as `settings`, `agents`, `bare` and `dangerously-skip-permissions` that + would bypass Band's wiring, permission gating or host-config isolation. `env` + reaches only the Claude CLI process; the host's `os.environ` is untouched. + +```python +import pytest + +from band.adapters.claude_sdk import ClaudeCLIOptions, ClaudeSDKAdapter + +adapter = ClaudeSDKAdapter(cli=ClaudeCLIOptions(extra_args={"debug-to-stderr": None})) +assert adapter.cli.extra_args == {"debug-to-stderr": None} + +with pytest.raises(ValueError, match="adapter-owned CLI flags"): + ClaudeCLIOptions(extra_args={"mcp-config": "{}"}) +``` diff --git a/src/band/adapters/__init__.py b/src/band/adapters/__init__.py index 7ea4d6f5a..a00d524a2 100644 --- a/src/band/adapters/__init__.py +++ b/src/band/adapters/__init__.py @@ -49,6 +49,7 @@ ) from band.adapters.agno import AgnoAdapter as AgnoAdapter from band.adapters.anthropic import AnthropicAdapter as AnthropicAdapter + from band.adapters.claude_sdk import ClaudeCLIOptions as ClaudeCLIOptions from band.adapters.claude_sdk import ClaudeSDKAdapter as ClaudeSDKAdapter from band.adapters.codex import ( CodexAdapter as CodexAdapter, @@ -115,7 +116,7 @@ langgraph=["LangGraphAdapter"], anthropic=["AnthropicAdapter"], pydantic_ai=["PydanticAIAdapter"], - claude_sdk=["ClaudeSDKAdapter"], + claude_sdk=["ClaudeCLIOptions", "ClaudeSDKAdapter"], copilot_sdk=["CopilotSDKAdapter", "CopilotSDKAdapterConfig"], copilot_acp=["CopilotACPAdapter", "CopilotACPAdapterConfig"], cursor_acp=["CursorACPAdapter", "CursorACPAdapterConfig"], diff --git a/src/band/adapters/claude_sdk.py b/src/band/adapters/claude_sdk.py index 15f9b71d2..8c21e3280 100644 --- a/src/band/adapters/claude_sdk.py +++ b/src/band/adapters/claude_sdk.py @@ -48,6 +48,7 @@ PermissionMode, PermissionResultAllow, PermissionResultDeny, + SdkPluginConfig, ToolPermissionContext, ) @@ -56,6 +57,7 @@ _CLAUDE_SDK_AVAILABLE = False from band_sdk_core import AgentFailure, is_authorized_sender +from pydantic import BaseModel, ConfigDict, Field, field_validator from typing_extensions import Unpack from band.converters.claude_sdk import ( @@ -71,6 +73,7 @@ from band.core.simple_adapter import SimpleAdapter from band.core.turn_lifecycle import ApprovalInterruptMixin from band.core.types import ( + ApprovalMode, Capability, Emit, FeatureKwargs, @@ -146,8 +149,103 @@ _PROVIDER = "claude_sdk" -# Approval flow types (mirrors Codex adapter patterns) -ApprovalMode = Literal["auto_accept", "auto_decline", "manual"] +# Flags the SDK emits from the options the adapter builds (pinned against the +# SDK's real command by test_cli_options.py). +SDK_OWNED_CLI_FLAGS: frozenset[str] = frozenset( + { + "add-dir", + "allowedTools", + "append-system-prompt", + "effort", + "fallback-model", + "input-format", + "max-thinking-tokens", + "mcp-config", + "model", + "output-format", + "permission-mode", + "permission-prompt-tool", + "plugin-dir", + "resume", + "setting-sources", + "verbose", + } +) +# Flags the adapter never sets that would still unhook the Band tools, bypass +# its permission gating or host-config isolation, or break per-room sessions. +BAND_UNSAFE_CLI_FLAGS: frozenset[str] = frozenset( + { + "allowed-tools", + "disallowedTools", + "disallowed-tools", + "tools", + "strict-mcp-config", + "system-prompt", + "system-prompt-file", + "settings", + "agents", + "bare", + "dangerously-skip-permissions", + "allow-dangerously-skip-permissions", + "continue", + "fork-session", + "session-id", + "print", + } +) +RESERVED_CLI_FLAGS: frozenset[str] = SDK_OWNED_CLI_FLAGS | BAND_UNSAFE_CLI_FLAGS + + +class ClaudeCLIOptions(BaseModel): + """How the Claude CLI process is launched; never how Band is wired into it. + + Attributes: + cli_path: ``claude`` executable to launch instead of the one bundled + with ``claude-agent-sdk``. + plugin_dirs: Local Claude Code plugin folders to load; their skills + load without changing ``cwd`` or ``setting_sources``. + add_dirs: Additional directories Claude may access (``--add-dir``). + env: Extra environment variables for the CLI process only; the host's + ``os.environ`` is left untouched. + extra_args: Additional CLI flags by bare name, ``{"flag": "value"}`` + or ``{"flag": None}`` for a bare flag. Flags in + ``RESERVED_CLI_FLAGS`` are rejected. + """ + + model_config = ConfigDict(frozen=True) + + cli_path: str | None = None + plugin_dirs: tuple[str, ...] = () + add_dirs: tuple[str, ...] = () + env: dict[str, str] = Field(default_factory=dict) + extra_args: dict[str, str | None] = Field(default_factory=dict) + + @field_validator("extra_args") + @classmethod + def _reject_unsafe_flags( + cls, extra_args: dict[str, str | None] + ) -> dict[str, str | None]: + if dashed := sorted(flag for flag in extra_args if flag.startswith("-")): + raise ValueError(f"extra_args keys are bare flag names: {dashed}") + if reserved := sorted(RESERVED_CLI_FLAGS.intersection(extra_args)): + raise ValueError( + f"extra_args may not set adapter-owned CLI flags: {reserved}" + ) + return extra_args + + def sdk_fields(self) -> dict[str, Any]: + """The ``ClaudeAgentOptions`` fields these options own.""" + return { + "cli_path": self.cli_path, + "plugins": [ + SdkPluginConfig(type="local", path=path) for path in self.plugin_dirs + ], + "add_dirs": list(self.add_dirs), + "env": dict(self.env), + "extra_args": dict(self.extra_args), + } + + ApprovalDecision = Literal["accept", "decline"] # dontAsk denies every prompt without calling can_use_tool, so no approval_mode @@ -333,6 +431,7 @@ def __init__( additional_tools: list[CustomToolDef] | None = None, cwd: str | None = None, setting_sources: list[str] | None = None, + cli: ClaudeCLIOptions | None = None, # Chat-based approval flow (opt-in) approval_mode: ApprovalMode | None = None, approval_text_notifications: bool = True, @@ -369,6 +468,8 @@ def __init__( tuples. These are converted to MCP tools internally. cwd: Working directory for Claude Code sessions. If set, Claude Code will operate in this directory (e.g., a mounted git repo). + cli: How the Claude CLI process is launched (executable, plugins, + extra directories, env, extra flags); see ``ClaudeCLIOptions``. approval_mode: Chat-based approval mode. ``None`` (default) disables chat-based approval -- the SDK's ``permission_mode`` controls approvals entirely. Set to ``"manual"`` to route approval @@ -420,6 +521,7 @@ def __init__( if cwd and not Path(cwd).is_dir(): raise ValueError(f"cwd does not exist or is not a directory: {cwd}") self.cwd = cwd + self.cli = cli or ClaudeCLIOptions() # Which host settings the CLI loads (skills/subagents/settings from # ~/.claude and ./.claude). Default isolates the bridged agent so its # capabilities are defined here, not by whatever config sits on the host @@ -542,6 +644,7 @@ async def on_started(self, agent_name: str, agent_description: str) -> None: # cast: the public param is list[str]; the SDK types it as a list of the # "user"/"project"/"local" literals. The CLI validates the values. setting_sources=cast("Any", self.setting_sources), + **self.cli.sdk_fields(), ) # Add extended thinking if configured diff --git a/src/band/adapters/codex.py b/src/band/adapters/codex.py index 4e059ad44..4fbce5342 100644 --- a/src/band/adapters/codex.py +++ b/src/band/adapters/codex.py @@ -43,6 +43,7 @@ from band.core.turn_lifecycle import ApprovalInterruptMixin from band.core.types import ( AgentInput, + ApprovalMode, Capability, Emit, FeatureKwargs, @@ -111,7 +112,6 @@ def _image_content_items(result: dict[str, Any]) -> list[dict[str, Any]]: TransportKind = Literal["stdio", "ws"] -ApprovalMode = Literal["auto_accept", "auto_decline", "manual"] ApprovalDecision = Literal["accept", "acceptForSession", "decline"] _REASONING_SUMMARIES = {"auto", "concise", "detailed", "none"} diff --git a/src/band/adapters/cursor_acp.py b/src/band/adapters/cursor_acp.py index 76d724e0c..e009b4e0b 100644 --- a/src/band/adapters/cursor_acp.py +++ b/src/band/adapters/cursor_acp.py @@ -13,7 +13,7 @@ from band.client.streaming import ControlMode from band.core.protocols import AgentToolsProtocol -from band.core.types import FeatureKwargs, PlatformMessage +from band.core.types import ApprovalMode, FeatureKwargs, PlatformMessage from band.integrations.acp.client_adapter import ( ACPClientAdapter, ACPPermissionRequest, @@ -45,7 +45,6 @@ logger = logging.getLogger(__name__) DEFAULT_CURSOR_ACP_COMMAND: tuple[str, ...] = ("agent", "acp") -ApprovalMode = Literal["manual", "auto_accept", "auto_decline"] QuestionMode = Literal["manual", "auto_first", "auto_cancel"] PlanMode = Literal["manual", "auto_accept", "auto_decline"] DecisionKind = Literal["permission", "question", "plan"] diff --git a/src/band/adapters/opencode/config.py b/src/band/adapters/opencode/config.py index cc2022034..025b6da09 100644 --- a/src/band/adapters/opencode/config.py +++ b/src/band/adapters/opencode/config.py @@ -6,7 +6,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict -ApprovalMode = Literal["manual", "auto_accept", "auto_decline"] +from band.core.types import ApprovalMode + QuestionMode = Literal["manual", "auto_reject"] ApprovalReply = Literal["once", "always", "reject"] diff --git a/src/band/core/types.py b/src/band/core/types.py index cb9138313..6eecda592 100644 --- a/src/band/core/types.py +++ b/src/band/core/types.py @@ -387,6 +387,11 @@ class PlatformConnection: ws_url: str +# How an adapter settles a tool-permission request: ask in chat, or answer +# it automatically. +ApprovalMode = Literal["manual", "auto_accept", "auto_decline"] + + class FeatureKwargs(TypedDict, total=False): """The feature keywords every ``SimpleAdapter`` constructor accepts. diff --git a/tests/adapters/claude_sdk/test_cli_options.py b/tests/adapters/claude_sdk/test_cli_options.py new file mode 100644 index 000000000..9aa0d5dc9 --- /dev/null +++ b/tests/adapters/claude_sdk/test_cli_options.py @@ -0,0 +1,103 @@ +"""CLI launch options a host sets on the adapter reach the Claude CLI.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from pathlib import Path + +import pytest +from claude_agent_sdk import ClaudeAgentOptions +from claude_agent_sdk._internal.transport.subprocess_cli import SubprocessCLITransport +from claude_agent_sdk.types import _configure_can_use_tool + +from band.adapters.claude_sdk import SDK_OWNED_CLI_FLAGS, ClaudeCLIOptions +from tests.adapters.claude_sdk.helpers import SEND_MESSAGE_MCP_NAME, ClaudeRoom +from tests.paths import host_absolute_path + +OpenRoom = Callable[..., Awaitable[ClaudeRoom]] + + +async def _cli_options( + room: ClaudeRoom, session_id: str | None = None +) -> ClaudeAgentOptions: + """The options the room's CLI process was started with.""" + room.claude.script([room.model_reply("Hello.")]) + await room.send("hi", session_id=session_id) + [session] = room.claude.sessions + return session.options + + +def _emitted_flags(options: ClaudeAgentOptions) -> set[str]: + """The ``--flags`` the SDK's own connect path would launch the CLI with.""" + transport = SubprocessCLITransport( + prompt="", options=_configure_can_use_tool(options) + ) + # _build_command refuses to run until connect() has located the binary. + transport._cli_path = "claude" + return { + token.removeprefix("--").partition("=")[0] + for token in transport._build_command() + if token.startswith("--") + } + + +async def test_cli_options_reach_the_cli(claude_room: OpenRoom, tmp_path: Path) -> None: + cli = ClaudeCLIOptions( + cli_path=host_absolute_path("opt", "claude", "bin", "claude"), + plugin_dirs=(str(tmp_path / "plugin"),), + add_dirs=(str(tmp_path / "shared"),), + env={"ANTHROPIC_API_KEY": "per-agent-key"}, + extra_args={"debug-to-stderr": None}, + ) + room = await claude_room(cli=cli) + + options = await _cli_options(room) + + assert options.plugins == [{"type": "local", "path": str(tmp_path / "plugin")}] + assert options.cli_path == cli.cli_path + assert options.env == {"ANTHROPIC_API_KEY": "per-agent-key"} + assert options.add_dirs == [str(tmp_path / "shared")] + assert options.extra_args == {"debug-to-stderr": None} + + +async def test_plugins_leave_band_wiring_in_place( + claude_room: OpenRoom, tmp_path: Path +) -> None: + room = await claude_room(cli=ClaudeCLIOptions(plugin_dirs=(str(tmp_path),))) + + options = await _cli_options(room) + + assert list(options.mcp_servers) == ["band"] + assert SEND_MESSAGE_MCP_NAME in options.allowed_tools + assert options.setting_sources == [] + + +async def test_sdk_owned_flags_match_what_the_adapter_makes_the_sdk_emit( + claude_room: OpenRoom, tmp_path: Path +) -> None: + room = await claude_room( + model="sonnet", + fallback_model="haiku", + effort="high", + max_thinking_tokens=1024, + cwd=str(tmp_path), + approval_mode="manual", + cli=ClaudeCLIOptions(plugin_dirs=(str(tmp_path),), add_dirs=(str(tmp_path),)), + ) + + options = await _cli_options(room, session_id="sess-1") + + assert _emitted_flags(options) == SDK_OWNED_CLI_FLAGS + + +@pytest.mark.parametrize( + "flag", ["mcp-config", "effort", "settings", "dangerously-skip-permissions"] +) +def test_extra_args_cannot_replace_adapter_owned_flags(flag: str) -> None: + with pytest.raises(ValueError, match="adapter-owned CLI flags"): + ClaudeCLIOptions(extra_args={flag: "x"}) + + +def test_extra_args_keys_are_bare_flag_names() -> None: + with pytest.raises(ValueError, match="bare flag names"): + ClaudeCLIOptions(extra_args={"--debug-to-stderr": None})