Skip to content
Open
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
3 changes: 3 additions & 0 deletions src/claude_agent_sdk/_internal/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions src/claude_agent_sdk/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
78 changes: 78 additions & 0 deletions tests/test_tool_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down