Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions src/claude_agent_sdk/_internal/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,13 +213,18 @@ async def _handle_control_request(self, request: SDKControlRequest) -> None:

# Convert PermissionResult to expected dict format
if isinstance(response, PermissionResultAllow):
response_data = {"allow": True}
response_data = {"behavior": "allow"}
if response.updated_input is not None:
response_data["input"] = response.updated_input
# TODO: Handle updatedPermissions when control protocol supports it
response_data["updatedInput"] = response.updated_input
if response.updated_permissions is not None:
response_data["updatedPermissions"] = [
permission.to_dict()
for permission in response.updated_permissions
]
elif isinstance(response, PermissionResultDeny):
response_data = {"allow": False, "reason": response.message}
# TODO: Handle interrupt flag when control protocol supports it
response_data = {"behavior": "deny", "message": response.message}
if response.interrupt:
response_data["interrupt"] = response.interrupt
else:
raise TypeError(
f"Tool permission callback must return PermissionResult (PermissionResultAllow or PermissionResultDeny), got {type(response)}"
Expand Down
36 changes: 36 additions & 0 deletions src/claude_agent_sdk/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,42 @@ class PermissionUpdate:
directories: list[str] | None = None
destination: PermissionUpdateDestination | None = None

def to_dict(self) -> dict[str, Any]:
"""Convert PermissionUpdate to dictionary format matching TypeScript control protocol."""
result: dict[str, Any] = {
"type": self.type,
}

# Add destination for all variants
if self.destination is not None:
result["destination"] = self.destination

# Handle different type variants
if self.type in ["addRules", "replaceRules", "removeRules"]:
# Rules-based variants require rules and behavior
if self.rules is not None:
result["rules"] = [
{
"toolName": rule.tool_name,
"ruleContent": rule.rule_content,
}
for rule in self.rules
]
if self.behavior is not None:
result["behavior"] = self.behavior

elif self.type == "setMode":
# Mode variant requires mode
if self.mode is not None:
result["mode"] = self.mode

elif self.type in ["addDirectories", "removeDirectories"]:
# Directory variants require directories
if self.directories is not None:
result["directories"] = self.directories

return result


# Tool callback types
@dataclass
Expand Down
8 changes: 4 additions & 4 deletions tests/test_tool_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ async def allow_callback(
# Check response was sent
assert len(transport.written_messages) == 1
response = transport.written_messages[0]
assert '"allow": true' in response
assert '"behavior": "allow"' in response

@pytest.mark.asyncio
async def test_permission_callback_deny(self):
Expand Down Expand Up @@ -125,8 +125,8 @@ async def deny_callback(
# Check response
assert len(transport.written_messages) == 1
response = transport.written_messages[0]
assert '"allow": false' in response
assert '"reason": "Security policy violation"' in response
assert '"behavior": "deny"' in response
assert '"message": "Security policy violation"' in response

@pytest.mark.asyncio
async def test_permission_callback_input_modification(self):
Expand Down Expand Up @@ -164,7 +164,7 @@ async def modify_callback(
# Check response includes modified input
assert len(transport.written_messages) == 1
response = transport.written_messages[0]
assert '"allow": true' in response
assert '"behavior": "allow"' in response
assert '"safe_mode": true' in response

@pytest.mark.asyncio
Expand Down