12 KiB
12 KiB
Tool Permission Callbacks
This guide covers tool permission callbacks for fine-grained control over tool usage.
Overview
Tool permission callbacks allow you to:
- Approve or deny tool usage
- Modify tool inputs before execution
- Implement complex permission logic
- Log tool usage
Choosing Your Permission Strategy
The SDK provides two ways to control tool permissions: permission modes (simple) and permission callbacks (advanced).
Quick Decision Guide
Use permission_mode when:
- You have simple, consistent permission policies
- You want to auto-approve/deny all file edits
- You don't need conditional logic
- You want minimal code
Use can_use_tool callback when:
- You need conditional approval logic
- You want to modify tool inputs before execution
- You need to block specific commands or patterns
- You want to log tool usage
- You need fine-grained control per tool
Permission Mode Options
from claude_agent_sdk import ClaudeAgentOptions
# Option 1: Auto-accept file edits (for automated workflows)
options = ClaudeAgentOptions(
permission_mode="acceptEdits",
allowed_tools=["Read", "Write", "Edit", "Bash"]
)
# Option 2: Require approval for edits (read-only automation)
options = ClaudeAgentOptions(
permission_mode="rejectEdits",
allowed_tools=["Read", "Grep", "Glob"]
)
# Option 3: Plan mode (no execution, just planning)
options = ClaudeAgentOptions(
permission_mode="plan",
allowed_tools=["Read", "Write", "Bash"]
)
# Option 4: Bypass all permissions (use with extreme caution)
options = ClaudeAgentOptions(
permission_mode="bypassPermissions",
allowed_tools=["Read", "Write", "Bash"]
)
When to Use Each Mode
| Mode | Behavior | Best For |
|---|---|---|
"acceptEdits" |
Auto-approves file edits (Write, Edit, etc.) | CI/CD pipelines, automated refactoring, code generation |
"rejectEdits" |
Auto-rejects file edits, allows reads | Analysis tasks, read-only auditing, code review |
"plan" |
No execution, planning only | Previewing changes, cost estimation, planning phase |
"bypassPermissions" |
Bypasses all permission checks | Testing, trusted environments only (⚠️ dangerous) |
"default" |
Uses can_use_tool callback if provided |
Custom permission logic (see below) |
Combining Mode + Callback
You can use both together - the mode provides baseline behavior and the callback adds custom logic:
async def custom_permissions(tool_name, input_data, context):
"""Custom logic on top of permission mode."""
# Block dangerous bash commands even if acceptEdits is set
if tool_name == "Bash":
command = input_data.get("command", "")
if "rm -rf /" in command:
return PermissionResultDeny(message="Dangerous command blocked")
return PermissionResultAllow()
options = ClaudeAgentOptions(
permission_mode="acceptEdits", # Auto-approve file edits
can_use_tool=custom_permissions, # But add custom bash validation
allowed_tools=["Read", "Write", "Edit", "Bash"]
)
Simple Use Cases: Just Use permission_mode
# Example 1: Automated code generation (accept all edits)
options = ClaudeAgentOptions(
permission_mode="acceptEdits",
allowed_tools=["Read", "Write", "Edit"]
)
# Example 2: Code analysis (read-only)
options = ClaudeAgentOptions(
permission_mode="rejectEdits",
allowed_tools=["Read", "Grep", "Glob"]
)
# Example 3: Planning phase (no execution)
options = ClaudeAgentOptions(
permission_mode="plan",
allowed_tools=["Read", "Write", "Bash"]
)
Permission Callbacks (Advanced)
For complex permission logic, use the can_use_tool callback.
Callback Signature
from claude_agent_sdk import (
PermissionResultAllow,
PermissionResultDeny,
ToolPermissionContext
)
async def permission_callback(
tool_name: str,
input_data: dict,
context: ToolPermissionContext
) -> PermissionResultAllow | PermissionResultDeny:
"""
Args:
tool_name: Name of the tool being used
input_data: Tool input parameters
context: Additional context (suggestions, etc.)
Returns:
PermissionResultAllow or PermissionResultDeny
"""
pass
Permission Results
Allow
# Simple allow
return PermissionResultAllow()
# Allow with modified input
return PermissionResultAllow(
updated_input={"file_path": "/safe/output.txt"}
)
Deny
return PermissionResultDeny(
message="Cannot write to system directories"
)
Common Patterns
1. Allow Read-Only Tools
async def permission_callback(
tool_name: str,
input_data: dict,
context: ToolPermissionContext
) -> PermissionResultAllow | PermissionResultDeny:
"""Auto-allow read-only operations."""
# Always allow read operations
if tool_name in ["Read", "Glob", "Grep"]:
return PermissionResultAllow()
# Deny or ask for other tools
return PermissionResultDeny(
message=f"Tool {tool_name} requires approval"
)
2. Block Dangerous Commands
async def permission_callback(
tool_name: str,
input_data: dict,
context: ToolPermissionContext
) -> PermissionResultAllow | PermissionResultDeny:
"""Block dangerous bash commands."""
if tool_name == "Bash":
command = input_data.get("command", "")
dangerous = ["rm -rf", "sudo", "chmod 777", "dd if=", "mkfs"]
for pattern in dangerous:
if pattern in command:
return PermissionResultDeny(
message=f"Dangerous command pattern: {pattern}"
)
return PermissionResultAllow()
3. Redirect File Writes
async def permission_callback(
tool_name: str,
input_data: dict,
context: ToolPermissionContext
) -> PermissionResultAllow | PermissionResultDeny:
"""Redirect writes to safe directory."""
if tool_name in ["Write", "Edit", "MultiEdit"]:
file_path = input_data.get("file_path", "")
# Block system directories
if file_path.startswith("/etc/") or file_path.startswith("/usr/"):
return PermissionResultDeny(
message=f"Cannot write to system directory: {file_path}"
)
# Redirect to safe directory
if not file_path.startswith("./safe/"):
safe_path = f"./safe/{file_path.split('/')[-1]}"
modified_input = input_data.copy()
modified_input["file_path"] = safe_path
return PermissionResultAllow(
updated_input=modified_input
)
return PermissionResultAllow()
4. Log Tool Usage
tool_usage_log = []
async def permission_callback(
tool_name: str,
input_data: dict,
context: ToolPermissionContext
) -> PermissionResultAllow | PermissionResultDeny:
"""Log all tool usage."""
# Log the request
tool_usage_log.append({
"tool": tool_name,
"input": input_data,
"suggestions": context.suggestions
})
print(f"Tool: {tool_name}")
print(f"Input: {input_data}")
return PermissionResultAllow()
5. Interactive Approval
async def permission_callback(
tool_name: str,
input_data: dict,
context: ToolPermissionContext
) -> PermissionResultAllow | PermissionResultDeny:
"""Ask user for permission on unknown tools."""
# Auto-allow safe tools
if tool_name in ["Read", "Grep", "Glob"]:
return PermissionResultAllow()
# Auto-deny dangerous tools
if tool_name == "Bash":
command = input_data.get("command", "")
if "rm -rf" in command:
return PermissionResultDeny(message="Dangerous command")
# Ask user for other tools
print(f"\nTool: {tool_name}")
print(f"Input: {input_data}")
user_input = input("Allow? (y/N): ").strip().lower()
if user_input in ("y", "yes"):
return PermissionResultAllow()
else:
return PermissionResultDeny(message="User denied permission")
Configuration
Set the callback in ClaudeAgentOptions:
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
options = ClaudeAgentOptions(
can_use_tool=permission_callback,
permission_mode="default", # Ensure callbacks are invoked
cwd="."
)
async with ClaudeSDKClient(options) as client:
await client.query("List files and create hello.py")
async for message in client.receive_response():
# Process messages
pass
Permission Modes
| Mode | Behavior | Use Case |
|---|---|---|
"default" |
Invokes callback for every tool | Fine-grained control |
"acceptEdits" |
Auto-approves file edits | Automated workflows |
"rejectEdits" |
Auto-rejects file edits | Read-only mode |
Complete Example
import asyncio
from claude_agent_sdk import (
ClaudeAgentOptions,
ClaudeSDKClient,
PermissionResultAllow,
PermissionResultDeny,
ToolPermissionContext,
)
# Track usage
tool_log = []
async def safe_permission_callback(
tool_name: str,
input_data: dict,
context: ToolPermissionContext
) -> PermissionResultAllow | PermissionResultDeny:
"""Safe permission callback with logging."""
# Log usage
tool_log.append({"tool": tool_name, "input": input_data})
# Always allow read operations
if tool_name in ["Read", "Glob", "Grep"]:
print(f"✅ Auto-allow: {tool_name}")
return PermissionResultAllow()
# Check writes to system directories
if tool_name in ["Write", "Edit"]:
file_path = input_data.get("file_path", "")
if file_path.startswith("/etc/"):
print(f"❌ Blocked: write to {file_path}")
return PermissionResultDeny(
message=f"Cannot write to system directory"
)
# Check dangerous bash commands
if tool_name == "Bash":
command = input_data.get("command", "")
if "rm -rf" in command or "sudo" in command:
print(f"❌ Blocked: dangerous command")
return PermissionResultDeny(
message="Dangerous command pattern detected"
)
print(f"✅ Allowed: {tool_name}")
return PermissionResultAllow()
async def main():
options = ClaudeAgentOptions(
can_use_tool=safe_permission_callback,
permission_mode="default",
allowed_tools=["Read", "Write", "Bash"]
)
async with ClaudeSDKClient(options) as client:
await client.query("List files and create hello.py")
async for message in client.receive_response():
# Process messages
pass
# Print usage summary
print("\nTool Usage Summary:")
for entry in tool_log:
print(f" {entry['tool']}: {entry['input']}")
if __name__ == "__main__":
asyncio.run(main())
Best Practices
- Return early - Check tool_name first and return quickly for unmatched tools
- Be defensive - Use
.get()to safely access input_data fields - Log decisions - Track what was allowed/denied for debugging
- Clear messages - Denial messages should explain why
- Test thoroughly - Verify callback logic with different tool types
Anti-Patterns
❌ Assuming input structure
# Crashes if command key doesn't exist
command = input_data["command"]
✅ Safe access
command = input_data.get("command", "")
❌ Silent denials
return PermissionResultDeny() # No message
✅ Informative denials
return PermissionResultDeny(
message="Cannot write to system directories for safety"
)
❌ Checking all tools for Bash-specific logic
# This crashes on non-Bash tools
async def callback(tool_name, input_data, context):
command = input_data["command"] # Only Bash has "command"
✅ Filter by tool_name first
async def callback(tool_name, input_data, context):
if tool_name != "Bash":
return PermissionResultAllow()
command = input_data.get("command", "")
# Now safe to check command