From 4f4fcfe3f34572703934583006ea2e5fd3a026b2 Mon Sep 17 00:00:00 2001 From: Kashyap Murali Date: Wed, 3 Sep 2025 09:05:51 -0700 Subject: [PATCH] feat: Full TypeScript SDK compatibility for tool permissions - Align PermissionResult structure with TypeScript SDK - Add PermissionResultAllow with updatedInput and updatedPermissions - Add PermissionResultDeny with message and interrupt flag - Remove misleading ToolPermissionResponse alias (no actual backward compat) - Add PermissionUpdate types matching TypeScript - PermissionUpdate with all update types - PermissionRuleValue for rule definitions - PermissionUpdateDestination and PermissionBehavior literals - Update all tests and examples to use new types - Update Query handler to properly handle Allow/Deny variants Breaking change: Callbacks must now return PermissionResultAllow or PermissionResultDeny instead of the old ToolPermissionResponse format. This ensures full compatibility with TypeScript SDK's permission system. --- examples/tool_permission_callback.py | 41 +++++++++------------ src/claude_code_sdk/__init__.py | 10 ++++-- src/claude_code_sdk/_internal/query.py | 31 +++++++++------- src/claude_code_sdk/types.py | 50 +++++++++++++++++++++----- tests/test_tool_callbacks.py | 30 +++++++--------- 5 files changed, 99 insertions(+), 63 deletions(-) diff --git a/examples/tool_permission_callback.py b/examples/tool_permission_callback.py index c5422ec..ccff319 100644 --- a/examples/tool_permission_callback.py +++ b/examples/tool_permission_callback.py @@ -12,10 +12,11 @@ from claude_code_sdk import ( AssistantMessage, ClaudeCodeOptions, ClaudeSDKClient, + PermissionResultAllow, + PermissionResultDeny, ResultMessage, TextBlock, ToolPermissionContext, - ToolPermissionResponse, ) # Track tool usage for demonstration @@ -26,7 +27,7 @@ async def my_permission_callback( tool_name: str, input_data: dict, context: ToolPermissionContext -) -> ToolPermissionResponse: +) -> PermissionResultAllow | PermissionResultDeny: """Control tool permissions based on tool type and input.""" # Log the tool request @@ -42,19 +43,15 @@ async def my_permission_callback( # Always allow read operations if tool_name in ["Read", "Glob", "Grep"]: print(f" ✅ Automatically allowing {tool_name} (read-only operation)") - return ToolPermissionResponse( - allow=True, - reason="Read operations are always allowed" - ) + return PermissionResultAllow() # Deny write operations to system directories if tool_name in ["Write", "Edit", "MultiEdit"]: file_path = input_data.get("file_path", "") if file_path.startswith("/etc/") or file_path.startswith("/usr/"): print(f" ❌ Denying write to system directory: {file_path}") - return ToolPermissionResponse( - allow=False, - reason=f"Cannot write to system directory: {file_path}" + return PermissionResultDeny( + message=f"Cannot write to system directory: {file_path}" ) # Redirect writes to a safe directory @@ -63,10 +60,8 @@ async def my_permission_callback( print(f" ⚠️ Redirecting write from {file_path} to {safe_path}") modified_input = input_data.copy() modified_input["file_path"] = safe_path - return ToolPermissionResponse( - allow=True, - input=modified_input, - reason=f"Redirected to safe path: {safe_path}" + return PermissionResultAllow( + updatedInput=modified_input ) # Check dangerous bash commands @@ -77,27 +72,25 @@ async def my_permission_callback( for dangerous in dangerous_commands: if dangerous in command: print(f" ❌ Denying dangerous command: {command}") - return ToolPermissionResponse( - allow=False, - reason=f"Dangerous command pattern detected: {dangerous}" + return PermissionResultDeny( + message=f"Dangerous command pattern detected: {dangerous}" ) # Allow but log the command print(f" ✅ Allowing bash command: {command}") - return ToolPermissionResponse( - allow=True, - reason="Command appears safe" - ) + return PermissionResultAllow() # For all other tools, ask the user print(f" ❓ Unknown tool: {tool_name}") print(f" Input: {json.dumps(input_data, indent=6)}") user_input = input(" Allow this tool? (y/N): ").strip().lower() - return ToolPermissionResponse( - allow=user_input in ("y", "yes"), - reason=f"User {'approved' if user_input in ('y', 'yes') else 'denied'}" - ) + if user_input in ("y", "yes"): + return PermissionResultAllow() + else: + return PermissionResultDeny( + message="User denied permission" + ) async def main(): diff --git a/src/claude_code_sdk/__init__.py b/src/claude_code_sdk/__init__.py index 8737f38..2f2d323 100644 --- a/src/claude_code_sdk/__init__.py +++ b/src/claude_code_sdk/__init__.py @@ -21,12 +21,15 @@ from .types import ( McpServerConfig, Message, PermissionMode, + PermissionResult, + PermissionResultAllow, + PermissionResultDeny, + PermissionUpdate, ResultMessage, SystemMessage, TextBlock, ThinkingBlock, ToolPermissionContext, - ToolPermissionResponse, ToolResultBlock, ToolUseBlock, UserMessage, @@ -57,7 +60,10 @@ __all__ = [ # Tool callbacks "CanUseTool", "ToolPermissionContext", - "ToolPermissionResponse", + "PermissionResult", + "PermissionResultAllow", + "PermissionResultDeny", + "PermissionUpdate", "HookCallback", "HookContext", "HookMatcher", diff --git a/src/claude_code_sdk/_internal/query.py b/src/claude_code_sdk/_internal/query.py index 7b08b9a..8e6d9be 100644 --- a/src/claude_code_sdk/_internal/query.py +++ b/src/claude_code_sdk/_internal/query.py @@ -10,12 +10,14 @@ from typing import Any import anyio from ..types import ( + PermissionResult, + PermissionResultAllow, + PermissionResultDeny, SDKControlPermissionRequest, SDKControlRequest, SDKControlResponse, SDKHookCallbackRequest, ToolPermissionContext, - ToolPermissionResponse, ) from .transport import Transport @@ -197,17 +199,22 @@ class Query: context ) - # Convert ToolPermissionResponse to expected dict format - if not isinstance(response, ToolPermissionResponse): - raise TypeError(f"Tool permission callback must return ToolPermissionResponse, got {type(response)}") - - response_data = { - "allow": response.allow - } - if response.input is not None: - response_data["input"] = response.input - if response.reason is not None: - response_data["reason"] = response.reason + # Convert PermissionResult to expected dict format + if isinstance(response, PermissionResultAllow): + response_data = { + "allow": True + } + if response.updatedInput is not None: + response_data["input"] = response.updatedInput + # TODO: Handle updatedPermissions when control protocol supports it + elif isinstance(response, PermissionResultDeny): + response_data = { + "allow": False, + "reason": response.message + } + # TODO: Handle interrupt flag when control protocol supports it + else: + raise TypeError(f"Tool permission callback must return PermissionResult (PermissionResultAllow or PermissionResultDeny), got {type(response)}") elif subtype == "hook_callback": hook_callback_request: SDKHookCallbackRequest = request_data # type: ignore[assignment] diff --git a/src/claude_code_sdk/types.py b/src/claude_code_sdk/types.py index f308426..0b7beaf 100644 --- a/src/claude_code_sdk/types.py +++ b/src/claude_code_sdk/types.py @@ -14,27 +14,61 @@ except ImportError: PermissionMode = Literal["default", "acceptEdits", "plan", "bypassPermissions"] +# Permission Update types (matching TypeScript SDK) +PermissionUpdateDestination = Literal[ + "userSettings", + "projectSettings", + "localSettings", + "session" +] + +PermissionBehavior = Literal["allow", "deny", "ask"] + +@dataclass +class PermissionRuleValue: + """Permission rule value.""" + toolName: str + ruleContent: str | None = None + +@dataclass +class PermissionUpdate: + """Permission update configuration.""" + type: Literal["addRules", "replaceRules", "removeRules", "setMode", "addDirectories", "removeDirectories"] + rules: list[PermissionRuleValue] | None = None + behavior: PermissionBehavior | None = None + mode: PermissionMode | None = None + directories: list[str] | None = None + destination: PermissionUpdateDestination | None = None + # Tool callback types @dataclass class ToolPermissionContext: """Context information for tool permission callbacks.""" signal: Any | None = None # Future: abort signal support - suggestions: list[str] = field(default_factory=list) # Permission suggestions from CLI + suggestions: list[PermissionUpdate] = field(default_factory=list) # Permission suggestions from CLI +# Match TypeScript's PermissionResult structure +@dataclass +class PermissionResultAllow: + """Allow permission result.""" + behavior: Literal["allow"] = "allow" + updatedInput: dict[str, Any] | None = None + updatedPermissions: list[PermissionUpdate] | None = None + @dataclass -class ToolPermissionResponse: - """Response from tool permission callback.""" - - allow: bool - input: dict[str, Any] | None = None # Optional: modified input parameters - reason: str | None = None # Optional: reason for decision +class PermissionResultDeny: + """Deny permission result.""" + behavior: Literal["deny"] = "deny" + message: str = "" + interrupt: bool = False +PermissionResult = PermissionResultAllow | PermissionResultDeny CanUseTool = Callable[ [str, dict[str, Any], ToolPermissionContext], - Awaitable[ToolPermissionResponse] + Awaitable[PermissionResult] ] diff --git a/tests/test_tool_callbacks.py b/tests/test_tool_callbacks.py index 37a90a2..26663c4 100644 --- a/tests/test_tool_callbacks.py +++ b/tests/test_tool_callbacks.py @@ -6,8 +6,9 @@ from claude_code_sdk import ( ClaudeCodeOptions, HookContext, HookMatcher, + PermissionResultAllow, + PermissionResultDeny, ToolPermissionContext, - ToolPermissionResponse, ) from claude_code_sdk._internal.query import Query from claude_code_sdk._internal.transport import Transport @@ -55,12 +56,12 @@ class TestToolPermissionCallbacks: tool_name: str, input_data: dict, context: ToolPermissionContext - ) -> ToolPermissionResponse: + ) -> PermissionResultAllow: nonlocal callback_invoked callback_invoked = True assert tool_name == "TestTool" assert input_data == {"param": "value"} - return ToolPermissionResponse(allow=True, reason="Test allow") + return PermissionResultAllow() transport = MockTransport() query = Query( @@ -91,7 +92,6 @@ class TestToolPermissionCallbacks: assert len(transport.written_messages) == 1 response = transport.written_messages[0] assert '"allow": true' in response - assert '"reason": "Test allow"' in response @pytest.mark.asyncio async def test_permission_callback_deny(self): @@ -100,10 +100,9 @@ class TestToolPermissionCallbacks: tool_name: str, input_data: dict, context: ToolPermissionContext - ) -> ToolPermissionResponse: - return ToolPermissionResponse( - allow=False, - reason="Security policy violation" + ) -> PermissionResultDeny: + return PermissionResultDeny( + message="Security policy violation" ) transport = MockTransport() @@ -140,14 +139,12 @@ class TestToolPermissionCallbacks: tool_name: str, input_data: dict, context: ToolPermissionContext - ) -> ToolPermissionResponse: + ) -> PermissionResultAllow: # Modify the input to add safety flag modified_input = input_data.copy() modified_input["safe_mode"] = True - return ToolPermissionResponse( - allow=True, - input=modified_input, - reason="Modified for safety" + return PermissionResultAllow( + updatedInput=modified_input ) transport = MockTransport() @@ -176,7 +173,6 @@ class TestToolPermissionCallbacks: response = transport.written_messages[0] assert '"allow": true' in response assert '"safe_mode": true' in response - assert '"reason": "Modified for safety"' in response @pytest.mark.asyncio async def test_callback_exception_handling(self): @@ -185,7 +181,7 @@ class TestToolPermissionCallbacks: tool_name: str, input_data: dict, context: ToolPermissionContext - ) -> ToolPermissionResponse: + ) -> PermissionResultAllow: raise ValueError("Callback error") transport = MockTransport() @@ -292,8 +288,8 @@ class TestClaudeCodeOptionsIntegration: tool_name: str, input_data: dict, context: ToolPermissionContext - ) -> ToolPermissionResponse: - return ToolPermissionResponse(allow=True) + ) -> PermissionResultAllow: + return PermissionResultAllow() async def my_hook( input_data: dict,