mirror of
https://github.com/anthropics/claude-code-sdk-python.git
synced 2025-12-23 09:19:52 +00:00
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.
This commit is contained in:
parent
4f78e10691
commit
4f4fcfe3f3
5 changed files with 99 additions and 63 deletions
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue