Skip to content

Commit 52749f9

Browse files
committed
feat: forward blocked_path to ToolPermissionContext
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
1 parent b512f25 commit 52749f9

3 files changed

Lines changed: 81 additions & 0 deletions

File tree

src/claude_agent_sdk/_internal/query.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@ 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"),
357358
)
358359

359360
response = await self.can_use_tool(

src/claude_agent_sdk/types.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,11 @@ class ToolPermissionContext:
184184
Multiple tool calls in the same assistant message will have different tool_use_ids."""
185185
agent_id: str | None = None
186186
"""If running within the context of a sub-agent, the sub-agent's ID."""
187+
blocked_path: str | None = None
188+
"""Filesystem path that triggered this permission request, when the tool was
189+
blocked by an `additionalDirectories`/`allowedDirectories` constraint or a
190+
path-scoped permission rule. None when the request is not path-scoped (e.g.
191+
the tool itself is restricted regardless of inputs)."""
187192

188193

189194
# Match TypeScript's PermissionResult structure

tests/test_tool_callbacks.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,81 @@ 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_blocked_path(self):
254+
"""Test that blocked_path is forwarded to the context when set."""
255+
received_context = None
256+
257+
async def capture_callback(
258+
tool_name: str, input_data: dict, context: ToolPermissionContext
259+
) -> PermissionResultAllow:
260+
nonlocal received_context
261+
received_context = context
262+
return PermissionResultAllow()
263+
264+
transport = MockTransport()
265+
query = Query(
266+
transport=transport,
267+
is_streaming_mode=True,
268+
can_use_tool=capture_callback,
269+
hooks=None,
270+
)
271+
272+
request = {
273+
"type": "control_request",
274+
"request_id": "test-blockedpath",
275+
"request": {
276+
"subtype": "can_use_tool",
277+
"tool_name": "Read",
278+
"input": {"file_path": "/etc/passwd"},
279+
"permission_suggestions": [],
280+
"tool_use_id": "toolu_01PATH",
281+
"blocked_path": "/etc/passwd",
282+
},
283+
}
284+
285+
await query._handle_control_request(request)
286+
287+
assert received_context is not None
288+
assert received_context.blocked_path == "/etc/passwd"
289+
290+
@pytest.mark.asyncio
291+
async def test_permission_callback_missing_blocked_path(self):
292+
"""Test that blocked_path defaults to None when not sent."""
293+
received_context = None
294+
295+
async def capture_callback(
296+
tool_name: str, input_data: dict, context: ToolPermissionContext
297+
) -> PermissionResultAllow:
298+
nonlocal received_context
299+
received_context = context
300+
return PermissionResultAllow()
301+
302+
transport = MockTransport()
303+
query = Query(
304+
transport=transport,
305+
is_streaming_mode=True,
306+
can_use_tool=capture_callback,
307+
hooks=None,
308+
)
309+
310+
request = {
311+
"type": "control_request",
312+
"request_id": "test-nopath",
313+
"request": {
314+
"subtype": "can_use_tool",
315+
"tool_name": "TestTool",
316+
"input": {},
317+
"permission_suggestions": [],
318+
"tool_use_id": "toolu_01NOPATH",
319+
},
320+
}
321+
322+
await query._handle_control_request(request)
323+
324+
assert received_context is not None
325+
assert received_context.blocked_path is None
326+
252327
@pytest.mark.asyncio
253328
async def test_callback_exception_handling(self):
254329
"""Test that callback exceptions are properly handled."""

0 commit comments

Comments
 (0)