Files
gh-basher83-lunar-claude-pl…/skills/claude-agent-sdk/references/tool-permissions.md
2025-11-29 18:00:18 +08:00

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

  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

# 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