# 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 ```python 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: ```python 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 ```python # 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 ```python 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 ```python # Simple allow return PermissionResultAllow() # Allow with modified input return PermissionResultAllow( updated_input={"file_path": "/safe/output.txt"} ) ``` ### Deny ```python return PermissionResultDeny( message="Cannot write to system directories" ) ``` ## Common Patterns ### 1. Allow Read-Only Tools ```python 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 ```python 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 ```python 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 ```python 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 ```python 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`: ```python 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 ```python 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 1. **Return early** - Check tool_name first and return quickly for unmatched tools 2. **Be defensive** - Use `.get()` to safely access input_data fields 3. **Log decisions** - Track what was allowed/denied for debugging 4. **Clear messages** - Denial messages should explain why 5. **Test thoroughly** - Verify callback logic with different tool types ## Anti-Patterns ❌ **Assuming input structure** ```python # Crashes if command key doesn't exist command = input_data["command"] ``` ✅ **Safe access** ```python command = input_data.get("command", "") ``` ❌ **Silent denials** ```python return PermissionResultDeny() # No message ``` ✅ **Informative denials** ```python return PermissionResultDeny( message="Cannot write to system directories for safety" ) ``` ❌ **Checking all tools for Bash-specific logic** ```python # 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** ```python async def callback(tool_name, input_data, context): if tool_name != "Bash": return PermissionResultAllow() command = input_data.get("command", "") # Now safe to check command ```