Skip to content

Commit 68f0d7a

Browse files
feat: Add tool permission and hook callbacks support (#143)
## Summary Adds comprehensive support for tool permission callbacks and hook callbacks to the Python SDK, enabling fine-grained control over tool execution and custom event handling. ## Key Changes - **Tool Permission Callbacks**: Control which tools Claude can use and modify their inputs - type with async support - with suggestions from CLI - for structured responses - **Hook Callbacks**: React to events in the Claude workflow - type for event handlers - for conditional hook execution - Support for tool_use_start, tool_use_end events - **Integration**: Full plumbing through ClaudeCodeOptions → Client → Query - **Examples**: Comprehensive example showing permission control patterns - **Tests**: Coverage for all callback scenarios ## Implementation Details - Callbacks are registered during initialization phase - Control protocol handles can_use_tool and hook_callback requests - Backwards compatible with dict returns for tool permissions - Proper error handling and type safety throughout Builds on top of #139's control protocol implementation. --------- Co-authored-by: Dickson Tsai <[email protected]>
1 parent 9ef5785 commit 68f0d7a

File tree

7 files changed

+649
-11
lines changed

7 files changed

+649
-11
lines changed
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
#!/usr/bin/env python3
2+
"""Example: Tool Permission Callbacks.
3+
4+
This example demonstrates how to use tool permission callbacks to control
5+
which tools Claude can use and modify their inputs.
6+
"""
7+
8+
import asyncio
9+
import json
10+
11+
from claude_code_sdk import (
12+
AssistantMessage,
13+
ClaudeCodeOptions,
14+
ClaudeSDKClient,
15+
PermissionResultAllow,
16+
PermissionResultDeny,
17+
ResultMessage,
18+
TextBlock,
19+
ToolPermissionContext,
20+
)
21+
22+
# Track tool usage for demonstration
23+
tool_usage_log = []
24+
25+
26+
async def my_permission_callback(
27+
tool_name: str,
28+
input_data: dict,
29+
context: ToolPermissionContext
30+
) -> PermissionResultAllow | PermissionResultDeny:
31+
"""Control tool permissions based on tool type and input."""
32+
33+
# Log the tool request
34+
tool_usage_log.append({
35+
"tool": tool_name,
36+
"input": input_data,
37+
"suggestions": context.suggestions
38+
})
39+
40+
print(f"\n🔧 Tool Permission Request: {tool_name}")
41+
print(f" Input: {json.dumps(input_data, indent=2)}")
42+
43+
# Always allow read operations
44+
if tool_name in ["Read", "Glob", "Grep"]:
45+
print(f" ✅ Automatically allowing {tool_name} (read-only operation)")
46+
return PermissionResultAllow()
47+
48+
# Deny write operations to system directories
49+
if tool_name in ["Write", "Edit", "MultiEdit"]:
50+
file_path = input_data.get("file_path", "")
51+
if file_path.startswith("/etc/") or file_path.startswith("/usr/"):
52+
print(f" ❌ Denying write to system directory: {file_path}")
53+
return PermissionResultDeny(
54+
message=f"Cannot write to system directory: {file_path}"
55+
)
56+
57+
# Redirect writes to a safe directory
58+
if not file_path.startswith("/tmp/") and not file_path.startswith("./"):
59+
safe_path = f"./safe_output/{file_path.split('/')[-1]}"
60+
print(f" ⚠️ Redirecting write from {file_path} to {safe_path}")
61+
modified_input = input_data.copy()
62+
modified_input["file_path"] = safe_path
63+
return PermissionResultAllow(
64+
updatedInput=modified_input
65+
)
66+
67+
# Check dangerous bash commands
68+
if tool_name == "Bash":
69+
command = input_data.get("command", "")
70+
dangerous_commands = ["rm -rf", "sudo", "chmod 777", "dd if=", "mkfs"]
71+
72+
for dangerous in dangerous_commands:
73+
if dangerous in command:
74+
print(f" ❌ Denying dangerous command: {command}")
75+
return PermissionResultDeny(
76+
message=f"Dangerous command pattern detected: {dangerous}"
77+
)
78+
79+
# Allow but log the command
80+
print(f" ✅ Allowing bash command: {command}")
81+
return PermissionResultAllow()
82+
83+
# For all other tools, ask the user
84+
print(f" ❓ Unknown tool: {tool_name}")
85+
print(f" Input: {json.dumps(input_data, indent=6)}")
86+
user_input = input(" Allow this tool? (y/N): ").strip().lower()
87+
88+
if user_input in ("y", "yes"):
89+
return PermissionResultAllow()
90+
else:
91+
return PermissionResultDeny(
92+
message="User denied permission"
93+
)
94+
95+
96+
async def main():
97+
"""Run example with tool permission callbacks."""
98+
99+
print("=" * 60)
100+
print("Tool Permission Callback Example")
101+
print("=" * 60)
102+
print("\nThis example demonstrates how to:")
103+
print("1. Allow/deny tools based on type")
104+
print("2. Modify tool inputs for safety")
105+
print("3. Log tool usage")
106+
print("4. Prompt for unknown tools")
107+
print("=" * 60)
108+
109+
# Configure options with our callback
110+
options = ClaudeCodeOptions(
111+
can_use_tool=my_permission_callback,
112+
# Use default permission mode to ensure callbacks are invoked
113+
permission_mode="default",
114+
cwd="." # Set working directory
115+
)
116+
117+
# Create client and send a query that will use multiple tools
118+
async with ClaudeSDKClient(options) as client:
119+
print("\n📝 Sending query to Claude...")
120+
await client.query(
121+
"Please do the following:\n"
122+
"1. List the files in the current directory\n"
123+
"2. Create a simple Python hello world script at hello.py\n"
124+
"3. Run the script to test it"
125+
)
126+
127+
print("\n📨 Receiving response...")
128+
message_count = 0
129+
130+
async for message in client.receive_response():
131+
message_count += 1
132+
133+
if isinstance(message, AssistantMessage):
134+
# Print Claude's text responses
135+
for block in message.content:
136+
if isinstance(block, TextBlock):
137+
print(f"\n💬 Claude: {block.text}")
138+
139+
elif isinstance(message, ResultMessage):
140+
print("\n✅ Task completed!")
141+
print(f" Duration: {message.duration_ms}ms")
142+
if message.total_cost_usd:
143+
print(f" Cost: ${message.total_cost_usd:.4f}")
144+
print(f" Messages processed: {message_count}")
145+
146+
# Print tool usage summary
147+
print("\n" + "=" * 60)
148+
print("Tool Usage Summary")
149+
print("=" * 60)
150+
for i, usage in enumerate(tool_usage_log, 1):
151+
print(f"\n{i}. Tool: {usage['tool']}")
152+
print(f" Input: {json.dumps(usage['input'], indent=6)}")
153+
if usage['suggestions']:
154+
print(f" Suggestions: {usage['suggestions']}")
155+
156+
157+
if __name__ == "__main__":
158+
asyncio.run(main())

src/claude_code_sdk/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,25 @@
1616
from .query import query
1717
from .types import (
1818
AssistantMessage,
19+
CanUseTool,
1920
ClaudeCodeOptions,
2021
ContentBlock,
22+
HookCallback,
23+
HookContext,
24+
HookMatcher,
2125
McpSdkServerConfig,
2226
McpServerConfig,
2327
Message,
2428
PermissionMode,
29+
PermissionResult,
30+
PermissionResultAllow,
31+
PermissionResultDeny,
32+
PermissionUpdate,
2533
ResultMessage,
2634
SystemMessage,
2735
TextBlock,
2836
ThinkingBlock,
37+
ToolPermissionContext,
2938
ToolResultBlock,
3039
ToolUseBlock,
3140
UserMessage,
@@ -286,6 +295,16 @@ async def call_tool(name: str, arguments: dict) -> Any:
286295
"ToolUseBlock",
287296
"ToolResultBlock",
288297
"ContentBlock",
298+
# Tool callbacks
299+
"CanUseTool",
300+
"ToolPermissionContext",
301+
"PermissionResult",
302+
"PermissionResultAllow",
303+
"PermissionResultDeny",
304+
"PermissionUpdate",
305+
"HookCallback",
306+
"HookContext",
307+
"HookMatcher",
289308
# MCP Server Support
290309
"create_sdk_mcp_server",
291310
"tool",

src/claude_code_sdk/_internal/client.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,22 @@ class InternalClient:
1919
def __init__(self) -> None:
2020
"""Initialize the internal client."""
2121

22+
def _convert_hooks_to_internal_format(
23+
self, hooks: dict[str, list]
24+
) -> dict[str, list[dict[str, Any]]]:
25+
"""Convert HookMatcher format to internal Query format."""
26+
internal_hooks = {}
27+
for event, matchers in hooks.items():
28+
internal_hooks[event] = []
29+
for matcher in matchers:
30+
# Convert HookMatcher to internal dict format
31+
internal_matcher = {
32+
"matcher": matcher.matcher if hasattr(matcher, 'matcher') else None,
33+
"hooks": matcher.hooks if hasattr(matcher, 'hooks') else []
34+
}
35+
internal_hooks[event].append(internal_matcher)
36+
return internal_hooks
37+
2238
async def process_query(
2339
self,
2440
prompt: str | AsyncIterable[dict[str, Any]],
@@ -48,8 +64,8 @@ async def process_query(
4864
query = Query(
4965
transport=chosen_transport,
5066
is_streaming_mode=is_streaming,
51-
can_use_tool=None, # TODO: Add support for can_use_tool callback
52-
hooks=None, # TODO: Add support for hooks
67+
can_use_tool=options.can_use_tool,
68+
hooks=self._convert_hooks_to_internal_format(options.hooks) if options.hooks else None,
5369
sdk_mcp_servers=sdk_mcp_servers,
5470
)
5571

src/claude_code_sdk/_internal/query.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,14 @@
1515
)
1616

1717
from ..types import (
18+
PermissionResult,
19+
PermissionResultAllow,
20+
PermissionResultDeny,
1821
SDKControlPermissionRequest,
1922
SDKControlRequest,
2023
SDKControlResponse,
2124
SDKHookCallbackRequest,
25+
ToolPermissionContext,
2226
)
2327
from .transport import Transport
2428

@@ -195,15 +199,34 @@ async def _handle_control_request(self, request: SDKControlRequest) -> None:
195199
if not self.can_use_tool:
196200
raise Exception("canUseTool callback is not provided")
197201

198-
response_data = await self.can_use_tool(
202+
context = ToolPermissionContext(
203+
signal=None, # TODO: Add abort signal support
204+
suggestions=permission_request.get("permission_suggestions", [])
205+
)
206+
207+
response = await self.can_use_tool(
199208
permission_request["tool_name"],
200209
permission_request["input"],
201-
{
202-
"signal": None, # TODO: Add abort signal support
203-
"suggestions": permission_request.get("permission_suggestions"),
204-
},
210+
context
205211
)
206212

213+
# Convert PermissionResult to expected dict format
214+
if isinstance(response, PermissionResultAllow):
215+
response_data = {
216+
"allow": True
217+
}
218+
if response.updatedInput is not None:
219+
response_data["input"] = response.updatedInput
220+
# TODO: Handle updatedPermissions when control protocol supports it
221+
elif isinstance(response, PermissionResultDeny):
222+
response_data = {
223+
"allow": False,
224+
"reason": response.message
225+
}
226+
# TODO: Handle interrupt flag when control protocol supports it
227+
else:
228+
raise TypeError(f"Tool permission callback must return PermissionResult (PermissionResultAllow or PermissionResultDeny), got {type(response)}")
229+
207230
elif subtype == "hook_callback":
208231
hook_callback_request: SDKHookCallbackRequest = request_data # type: ignore[assignment]
209232
# Handle hook callback

src/claude_code_sdk/client.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,22 @@ def __init__(self, options: ClaudeCodeOptions | None = None):
100100
self._query: Any | None = None
101101
os.environ["CLAUDE_CODE_ENTRYPOINT"] = "sdk-py-client"
102102

103+
def _convert_hooks_to_internal_format(
104+
self, hooks: dict[str, list]
105+
) -> dict[str, list[dict[str, Any]]]:
106+
"""Convert HookMatcher format to internal Query format."""
107+
internal_hooks = {}
108+
for event, matchers in hooks.items():
109+
internal_hooks[event] = []
110+
for matcher in matchers:
111+
# Convert HookMatcher to internal dict format
112+
internal_matcher = {
113+
"matcher": matcher.matcher if hasattr(matcher, 'matcher') else None,
114+
"hooks": matcher.hooks if hasattr(matcher, 'hooks') else []
115+
}
116+
internal_hooks[event].append(internal_matcher)
117+
return internal_hooks
118+
103119
async def connect(
104120
self, prompt: str | AsyncIterable[dict[str, Any]] | None = None
105121
) -> None:
@@ -135,8 +151,8 @@ async def _empty_stream() -> AsyncIterator[dict[str, Any]]:
135151
self._query = Query(
136152
transport=self._transport,
137153
is_streaming_mode=True, # ClaudeSDKClient always uses streaming mode
138-
can_use_tool=None, # TODO: Add support for can_use_tool callback
139-
hooks=None, # TODO: Add support for hooks
154+
can_use_tool=self.options.can_use_tool,
155+
hooks=self._convert_hooks_to_internal_format(self.options.hooks) if self.options.hooks else None,
140156
sdk_mcp_servers=sdk_mcp_servers,
141157
)
142158

0 commit comments

Comments
 (0)