Skip to content

Commit 6597529

Browse files
authored
fix: deserialize permission_suggestions into PermissionUpdate instances (#920)
## Problem `ToolPermissionContext.suggestions` is typed as `list[PermissionUpdate]`, but `_internal/query.py` assigns the raw `permission_suggestions` list from the control protocol without deserializing, so at runtime it's `list[dict]`. This breaks the round-trip pattern where a `can_use_tool` callback echoes a suggestion back to persist it: ```python async def can_use_tool(tool_name, input_data, context): persist = [s for s in context.suggestions if s.destination == "localSettings"] # AttributeError: 'dict' object has no attribute 'destination' return PermissionResultAllow(updated_permissions=persist) # and even with dict access, the response path calls .to_dict() on each item: # AttributeError: 'dict' object has no attribute 'to_dict' ``` The equivalent works in the TypeScript SDK. ## Fix - Add `PermissionUpdate.from_dict()` as the inverse of `to_dict()`, rebuilding `PermissionRuleValue` entries from the camelCase wire keys. - Map `permission_suggestions` through it when constructing `ToolPermissionContext`. Suggestions now arrive as dataclass instances and can be returned directly in `PermissionResultAllow(updated_permissions=...)`. ## Tests - `TestPermissionUpdate` round-trip unit tests for `addRules`, `setMode`, `addDirectories`. - `test_permission_callback_suggestions_roundtrip` sends a wire-format suggestion, asserts the callback receives a `PermissionUpdate`, echoes it back, and checks the serialized response matches the input. - Fixed a fixture in `test_permission_callback_deny` that passed `["deny"]` as a suggestion; it only worked before because nothing parsed the list. Related: #624 tightens the type annotation on the control-protocol TypedDict but doesn't change runtime behavior; this PR makes the runtime match the annotation.
1 parent bdb3291 commit 6597529

4 files changed

Lines changed: 124 additions & 3 deletions

File tree

src/claude_agent_sdk/_internal/query.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
PermissionMode,
2020
PermissionResultAllow,
2121
PermissionResultDeny,
22+
PermissionUpdate,
2223
SDKControlPermissionRequest,
2324
SDKControlRequest,
2425
SDKControlResponse,
@@ -350,8 +351,12 @@ async def _handle_control_request(self, request: SDKControlRequest) -> None:
350351

351352
context = ToolPermissionContext(
352353
signal=None, # TODO: Add abort signal support
353-
suggestions=permission_request.get("permission_suggestions", [])
354-
or [],
354+
suggestions=[
355+
PermissionUpdate.from_dict(s)
356+
for s in (
357+
permission_request.get("permission_suggestions") or []
358+
)
359+
],
355360
tool_use_id=permission_request.get("tool_use_id"),
356361
agent_id=permission_request.get("agent_id"),
357362
blocked_path=permission_request.get("blocked_path"),

src/claude_agent_sdk/types.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,27 @@ def to_dict(self) -> dict[str, Any]:
169169

170170
return result
171171

172+
@classmethod
173+
def from_dict(cls, data: dict[str, Any]) -> "PermissionUpdate":
174+
"""Construct a PermissionUpdate from the control protocol dict format (inverse of to_dict)."""
175+
rules = None
176+
if data.get("rules") is not None:
177+
rules = [
178+
PermissionRuleValue(
179+
tool_name=r["toolName"],
180+
rule_content=r.get("ruleContent"),
181+
)
182+
for r in data["rules"]
183+
]
184+
return cls(
185+
type=data["type"],
186+
rules=rules,
187+
behavior=data.get("behavior"),
188+
mode=data.get("mode"),
189+
directories=data.get("directories"),
190+
destination=data.get("destination"),
191+
)
192+
172193

173194
# Tool callback types
174195
@dataclass

tests/test_tool_callbacks.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,58 @@ async def allow_callback(
9797
response = transport.written_messages[0]
9898
assert '"behavior": "allow"' in response
9999

100+
@pytest.mark.asyncio
101+
async def test_permission_callback_suggestions_roundtrip(self):
102+
"""Suggestions arrive as PermissionUpdate instances and can be echoed back."""
103+
from claude_agent_sdk.types import PermissionUpdate
104+
105+
seen_suggestions: list[Any] = []
106+
107+
async def always_allow(
108+
tool_name: str, input_data: dict, context: ToolPermissionContext
109+
) -> PermissionResultAllow:
110+
seen_suggestions.extend(context.suggestions)
111+
persist = [
112+
s for s in context.suggestions if s.destination == "localSettings"
113+
]
114+
return PermissionResultAllow(updated_permissions=persist)
115+
116+
transport = MockTransport()
117+
query = Query(
118+
transport=transport,
119+
is_streaming_mode=True,
120+
can_use_tool=always_allow,
121+
hooks=None,
122+
)
123+
124+
wire_suggestion = {
125+
"type": "addRules",
126+
"destination": "localSettings",
127+
"behavior": "allow",
128+
"rules": [{"toolName": "Bash", "ruleContent": "git status"}],
129+
}
130+
request = {
131+
"type": "control_request",
132+
"request_id": "test-roundtrip",
133+
"request": {
134+
"subtype": "can_use_tool",
135+
"tool_name": "Bash",
136+
"input": {"command": "git status"},
137+
"permission_suggestions": [wire_suggestion],
138+
},
139+
}
140+
141+
await query._handle_control_request(request)
142+
143+
assert len(seen_suggestions) == 1
144+
assert isinstance(seen_suggestions[0], PermissionUpdate)
145+
assert seen_suggestions[0].destination == "localSettings"
146+
147+
assert len(transport.written_messages) == 1
148+
response = json.loads(transport.written_messages[0])
149+
sent = response["response"]["response"]["updatedPermissions"]
150+
assert sent == [wire_suggestion]
151+
100152
@pytest.mark.asyncio
101153
async def test_permission_callback_deny(self):
102154
"""Test callback that denies tool execution."""
@@ -121,7 +173,7 @@ async def deny_callback(
121173
"subtype": "can_use_tool",
122174
"tool_name": "DangerousTool",
123175
"input": {"command": "rm -rf /"},
124-
"permission_suggestions": ["deny"],
176+
"permission_suggestions": [],
125177
},
126178
}
127179

tests/test_types.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
SubagentStartHookSpecificOutput,
1313
)
1414
from claude_agent_sdk.types import (
15+
PermissionRuleValue,
16+
PermissionUpdate,
1517
PostToolUseHookSpecificOutput,
1618
PreToolUseHookSpecificOutput,
1719
TextBlock,
@@ -22,6 +24,47 @@
2224
)
2325

2426

27+
class TestPermissionUpdate:
28+
"""Test PermissionUpdate wire-format conversion."""
29+
30+
def test_from_dict_to_dict_roundtrip_add_rules(self):
31+
wire = {
32+
"type": "addRules",
33+
"destination": "localSettings",
34+
"behavior": "allow",
35+
"rules": [
36+
{"toolName": "Bash", "ruleContent": "npm *"},
37+
{"toolName": "Read", "ruleContent": None},
38+
],
39+
}
40+
update = PermissionUpdate.from_dict(wire)
41+
assert update.type == "addRules"
42+
assert update.destination == "localSettings"
43+
assert update.behavior == "allow"
44+
assert update.rules == [
45+
PermissionRuleValue(tool_name="Bash", rule_content="npm *"),
46+
PermissionRuleValue(tool_name="Read", rule_content=None),
47+
]
48+
assert update.to_dict() == wire
49+
50+
def test_from_dict_set_mode(self):
51+
wire = {"type": "setMode", "mode": "acceptEdits", "destination": "session"}
52+
update = PermissionUpdate.from_dict(wire)
53+
assert update.mode == "acceptEdits"
54+
assert update.rules is None
55+
assert update.to_dict() == wire
56+
57+
def test_from_dict_directories(self):
58+
wire = {
59+
"type": "addDirectories",
60+
"directories": ["/tmp/a", "/tmp/b"],
61+
"destination": "userSettings",
62+
}
63+
update = PermissionUpdate.from_dict(wire)
64+
assert update.directories == ["/tmp/a", "/tmp/b"]
65+
assert update.to_dict() == wire
66+
67+
2568
class TestMessageTypes:
2669
"""Test message type creation and validation."""
2770

0 commit comments

Comments
 (0)