From 52749f9cf7b55c0e79bbce3130f1eb8233164201 Mon Sep 17 00:00:00 2001 From: notabene00 Date: Sat, 2 May 2026 15:53:35 +0300 Subject: [PATCH] feat: forward blocked_path to ToolPermissionContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI sends `blocked_path` on `SDKControlPermissionRequest` to indicate which filesystem path triggered a permission request (e.g. when a tool is blocked by `additionalDirectories`/`allowedDirectories` constraints or a path-scoped permission rule). The TypedDict already declares the field, but it was never forwarded to `ToolPermissionContext`, so custom `can_use_tool` callbacks couldn't see it. This is the missing piece relative to PR #459 — `tool_use_id` and `agent_id` were merged via #754, but `blocked_path` was left out. Surfacing it lets permission callbacks render meaningful prompts ("Read blocked: /etc/passwd"), implement smart auto-allow scoped to specific paths, and do security audit logging without re-parsing each tool's input shape. Changes: - Add `blocked_path: str | None = None` to `ToolPermissionContext` - Forward `permission_request.get("blocked_path")` in `_handle_control_request` - Two unit tests: present and missing --- src/claude_agent_sdk/_internal/query.py | 1 + src/claude_agent_sdk/types.py | 5 ++ tests/test_tool_callbacks.py | 75 +++++++++++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/src/claude_agent_sdk/_internal/query.py b/src/claude_agent_sdk/_internal/query.py index 0843453ef..95a55daaf 100644 --- a/src/claude_agent_sdk/_internal/query.py +++ b/src/claude_agent_sdk/_internal/query.py @@ -354,6 +354,7 @@ 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"), + blocked_path=permission_request.get("blocked_path"), ) response = await self.can_use_tool( diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index 9c2be63fe..2235f7cde 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -184,6 +184,11 @@ 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.""" + blocked_path: str | None = None + """Filesystem path that triggered this permission request, when the tool was + blocked by an `additionalDirectories`/`allowedDirectories` constraint or a + path-scoped permission rule. None when the request is not path-scoped (e.g. + the tool itself is restricted regardless of inputs).""" # Match TypeScript's PermissionResult structure diff --git a/tests/test_tool_callbacks.py b/tests/test_tool_callbacks.py index 749054b53..177d72bdd 100644 --- a/tests/test_tool_callbacks.py +++ b/tests/test_tool_callbacks.py @@ -249,6 +249,81 @@ 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_blocked_path(self): + """Test that blocked_path is forwarded to the context when set.""" + 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-blockedpath", + "request": { + "subtype": "can_use_tool", + "tool_name": "Read", + "input": {"file_path": "/etc/passwd"}, + "permission_suggestions": [], + "tool_use_id": "toolu_01PATH", + "blocked_path": "/etc/passwd", + }, + } + + await query._handle_control_request(request) + + assert received_context is not None + assert received_context.blocked_path == "/etc/passwd" + + @pytest.mark.asyncio + async def test_permission_callback_missing_blocked_path(self): + """Test that blocked_path 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-nopath", + "request": { + "subtype": "can_use_tool", + "tool_name": "TestTool", + "input": {}, + "permission_suggestions": [], + "tool_use_id": "toolu_01NOPATH", + }, + } + + await query._handle_control_request(request) + + assert received_context is not None + assert received_context.blocked_path is None + @pytest.mark.asyncio async def test_callback_exception_handling(self): """Test that callback exceptions are properly handled."""