From 5d2752131a07b9acf09341ee10b2061e70c6c537 Mon Sep 17 00:00:00 2001 From: Nik-Reddy Date: Wed, 15 Apr 2026 00:46:32 -0700 Subject: [PATCH] feat: forward hook_decision_reason from PreToolUse hooks to can_use_tool callback When a PreToolUse hook returns permissionDecision: 'ask' with a permissionDecisionReason, the reason string was not being forwarded to the SDK's can_use_tool callback, making it inaccessible to consumers building custom permission UIs. Changes: - Add hook_decision_reason field to SDKControlPermissionRequest - Add hook_decision_reason field to ToolPermissionContext - Pass hook_decision_reason through in Query._handle_control_request - Add tests for presence and absence of hook_decision_reason Fixes #816 --- src/claude_agent_sdk/_internal/query.py | 3 + src/claude_agent_sdk/types.py | 5 ++ tests/test_tool_callbacks.py | 78 +++++++++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/src/claude_agent_sdk/_internal/query.py b/src/claude_agent_sdk/_internal/query.py index 80b6d93c..f9d92e85 100644 --- a/src/claude_agent_sdk/_internal/query.py +++ b/src/claude_agent_sdk/_internal/query.py @@ -283,6 +283,9 @@ async def _handle_control_request(self, request: SDKControlRequest) -> None: or [], tool_use_id=permission_request.get("tool_use_id"), agent_id=permission_request.get("agent_id"), + hook_decision_reason=permission_request.get( + "hook_decision_reason" + ), ) response = await self.can_use_tool( diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index a82a8b9b..72765871 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -184,6 +184,10 @@ class ToolPermissionContext: Multiple tool calls in the same assistant message will have different tool_use_ids.""" agent_id: str | None = None """If running within the context of a sub-agent, the sub-agent's ID.""" + hook_decision_reason: str | None = None + """Reason string from a PreToolUse hook that returned ``permissionDecision: "ask"``. + SDK consumers can surface this in custom permission UIs to explain *why* + the hook is requesting confirmation.""" # Match TypeScript's PermissionResult structure @@ -1262,6 +1266,7 @@ class SDKControlPermissionRequest(TypedDict): blocked_path: str | None tool_use_id: str agent_id: NotRequired[str] + hook_decision_reason: NotRequired[str] class SDKControlInitializeRequest(TypedDict): diff --git a/tests/test_tool_callbacks.py b/tests/test_tool_callbacks.py index 749054b5..e6573697 100644 --- a/tests/test_tool_callbacks.py +++ b/tests/test_tool_callbacks.py @@ -249,6 +249,84 @@ async def capture_callback( assert received_context.tool_use_id == "toolu_01XYZ789" assert received_context.agent_id is None + @pytest.mark.asyncio + async def test_permission_callback_receives_hook_decision_reason(self): + """Test that hook_decision_reason is passed through to the context.""" + received_context = None + + async def capture_callback( + tool_name: str, input_data: dict, context: ToolPermissionContext + ) -> PermissionResultAllow: + nonlocal received_context + received_context = context + return PermissionResultAllow() + + transport = MockTransport() + query = Query( + transport=transport, + is_streaming_mode=True, + can_use_tool=capture_callback, + hooks=None, + ) + + request = { + "type": "control_request", + "request_id": "test-hook-reason", + "request": { + "subtype": "can_use_tool", + "tool_name": "Bash", + "input": {"command": "rm -rf /"}, + "permission_suggestions": [], + "tool_use_id": "toolu_01REASON", + "agent_id": "agent-789", + "hook_decision_reason": "Destructive command detected by safety hook", + }, + } + + await query._handle_control_request(request) + + assert received_context is not None + assert received_context.hook_decision_reason == "Destructive command detected by safety hook" + assert received_context.tool_use_id == "toolu_01REASON" + assert received_context.agent_id == "agent-789" + + @pytest.mark.asyncio + async def test_permission_callback_missing_hook_decision_reason(self): + """Test that hook_decision_reason defaults to None when not sent.""" + received_context = None + + async def capture_callback( + tool_name: str, input_data: dict, context: ToolPermissionContext + ) -> PermissionResultAllow: + nonlocal received_context + received_context = context + return PermissionResultAllow() + + transport = MockTransport() + query = Query( + transport=transport, + is_streaming_mode=True, + can_use_tool=capture_callback, + hooks=None, + ) + + request = { + "type": "control_request", + "request_id": "test-no-reason", + "request": { + "subtype": "can_use_tool", + "tool_name": "TestTool", + "input": {}, + "permission_suggestions": [], + "tool_use_id": "toolu_01NOREASON", + }, + } + + await query._handle_control_request(request) + + assert received_context is not None + assert received_context.hook_decision_reason is None + @pytest.mark.asyncio async def test_callback_exception_handling(self): """Test that callback exceptions are properly handled."""