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:
Kashyap Murali 2025-09-03 09:05:51 -07:00
parent 4f78e10691
commit 4f4fcfe3f3
No known key found for this signature in database
5 changed files with 99 additions and 63 deletions

View file

@ -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():

View file

@ -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",

View file

@ -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]

View file

@ -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]
]

View file

@ -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,