Skip to content

Commit 3caf665

Browse files
qing-antclaude
andauthored
feat: forward decision_reason and permission-display fields to ToolPermissionContext (#909)
Fixes #816. ## Summary The CLI already includes `decision_reason`, `blocked_path`, `title`, `display_name`, and `description` on the `can_use_tool` control request — and the TypeScript SDK already exposes them on its `ToolPermissionContext`. The Python SDK was dropping them on the floor. This adds the five fields to `ToolPermissionContext` and `SDKControlPermissionRequest`, and populates them in `Query._handle_control_request`. Now a `can_use_tool` callback can: - Show users *why* they're being prompted (`decision_reason` — e.g. a PreToolUse hook's `permissionDecisionReason`) - Render a richer permission prompt (`title` / `display_name` / `description`) without reconstructing it from `tool_name` + `input` - See which path triggered the prompt (`blocked_path`) All fields are optional (`str | None = None`), so this is backward-compatible. ## Testing - New unit test `test_permission_callback_receives_decision_reason` — fails on `main` with `AttributeError: 'ToolPermissionContext' object has no attribute 'blocked_path'`, passes here. - Full suite: 716 passed, 2 pre-existing failures in `test_transcript_mirror.py` (eager-flush, unrelated to this change). - `ruff check` / `ruff format`: clean. - `mypy src/`: 2 pre-existing errors in `_task_compat.py` (trio import-not-found), none introduced. - Live e2e proof in PR comment below. --- _Generated by [Claude Code](https://claude.ai/code/session_013yvWPbykUnMTtyRxaa51sK)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 42971da commit 3caf665

3 files changed

Lines changed: 75 additions & 0 deletions

File tree

src/claude_agent_sdk/_internal/query.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,11 @@ async def _handle_control_request(self, request: SDKControlRequest) -> None:
354354
or [],
355355
tool_use_id=permission_request.get("tool_use_id"),
356356
agent_id=permission_request.get("agent_id"),
357+
blocked_path=permission_request.get("blocked_path"),
358+
decision_reason=permission_request.get("decision_reason"),
359+
title=permission_request.get("title"),
360+
display_name=permission_request.get("display_name"),
361+
description=permission_request.get("description"),
357362
)
358363

359364
response = await self.can_use_tool(

src/claude_agent_sdk/types.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,22 @@ class ToolPermissionContext:
188188
field-ordering compatibility, so callers do not need to handle ``None``."""
189189
agent_id: str | None = None
190190
"""If running within the context of a sub-agent, the sub-agent's ID."""
191+
blocked_path: str | None = None
192+
"""The file path that triggered the permission request, if applicable.
193+
For example, when a Bash command tries to access a path outside allowed directories."""
194+
decision_reason: str | None = None
195+
"""Explains why this permission request was triggered.
196+
When a PreToolUse hook returns ``permissionDecision: "ask"`` with a
197+
``permissionDecisionReason``, that reason is forwarded here."""
198+
title: str | None = None
199+
"""Full permission prompt sentence (e.g. "Claude wants to read foo.txt").
200+
Use this as the primary prompt text when present instead of reconstructing
201+
from tool name + input."""
202+
display_name: str | None = None
203+
"""Short noun phrase for the tool action (e.g. "Read file"), suitable for
204+
button labels or compact UI."""
205+
description: str | None = None
206+
"""Human-readable subtitle for the permission UI."""
191207

192208

193209
# Match TypeScript's PermissionResult structure
@@ -1838,6 +1854,10 @@ class SDKControlPermissionRequest(TypedDict):
18381854
# TODO: Add PermissionUpdate type here
18391855
permission_suggestions: list[Any] | None
18401856
blocked_path: str | None
1857+
decision_reason: NotRequired[str]
1858+
title: NotRequired[str]
1859+
display_name: NotRequired[str]
1860+
description: NotRequired[str]
18411861
tool_use_id: str
18421862
agent_id: NotRequired[str]
18431863

tests/test_tool_callbacks.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,56 @@ async def capture_callback(
249249
assert received_context.tool_use_id == "toolu_01XYZ789"
250250
assert received_context.agent_id is None
251251

252+
@pytest.mark.asyncio
253+
async def test_permission_callback_receives_decision_reason(self):
254+
"""Test that decision_reason and permission-display fields are forwarded
255+
to the context (TS SDK parity, #816)."""
256+
received_context = None
257+
258+
async def capture_callback(
259+
tool_name: str, input_data: dict, context: ToolPermissionContext
260+
) -> PermissionResultAllow:
261+
nonlocal received_context
262+
received_context = context
263+
return PermissionResultAllow()
264+
265+
transport = MockTransport()
266+
query = Query(
267+
transport=transport,
268+
is_streaming_mode=True,
269+
can_use_tool=capture_callback,
270+
hooks=None,
271+
)
272+
273+
request = {
274+
"type": "control_request",
275+
"request_id": "test-reason",
276+
"request": {
277+
"subtype": "can_use_tool",
278+
"tool_name": "Bash",
279+
"input": {"command": "rm -rf /tmp/x"},
280+
"permission_suggestions": [],
281+
"tool_use_id": "toolu_01DEF456",
282+
"blocked_path": "/tmp/x",
283+
"decision_reason": "PreToolUse hook flagged this as destructive",
284+
"title": "Claude wants to run a Bash command",
285+
"display_name": "Bash",
286+
"description": "rm -rf /tmp/x",
287+
},
288+
}
289+
290+
await query._handle_control_request(request)
291+
292+
assert received_context is not None
293+
assert received_context.blocked_path == "/tmp/x"
294+
assert (
295+
received_context.decision_reason
296+
== "PreToolUse hook flagged this as destructive"
297+
)
298+
assert received_context.title == "Claude wants to run a Bash command"
299+
assert received_context.display_name == "Bash"
300+
assert received_context.description == "rm -rf /tmp/x"
301+
252302
@pytest.mark.asyncio
253303
async def test_callback_exception_handling(self):
254304
"""Test that callback exceptions are properly handled."""

0 commit comments

Comments
 (0)