GitHub - panuhorsmalahti/claude-code-permissions-hook: A PreToolUse hook for Claude Code for more granular permission controls

4 min read Original article ↗

A PreToolUse hook for Claude Code that provides granular control over which tools Claude can use, with support for allow/deny rules, pattern matching, and security exclusions.

NOTE: This is a workaround for current (December 2025) limitations in Claude Code permissions - setting Bash permissions doesn't work consistently. Built following Anthropic's hook guidelines.

This may be short-lived as Anthropic improves permissions. Use it if it helps, but you'll need basic Rust knowledge.

Features

  • All configuration is via a single .toml file - see example.toml for an example
  • Allow/deny rules with regex pattern matching for tool inputs
  • Exclude patterns for handling edge cases (e.g., block .. in allowed paths)
  • Audit logging of tool use decisions to JSON file
  • Delegates permission approval to LLM if static rules are not matched

Documentation

Installation

Requires Rust. Install via rustup if you want to use this project. Rust is fun!

Binary: target/release/claude-code-permissions-hook

Configuration

Create a TOML configuration file (see example.toml):

[audit]
audit_file = "/tmp/claude-tool-use.json"
# Audit level: off, matched (default), all
# - off: no auditing
# - matched: audit only tool use that hits a rule (allow/deny)
# - all: audit everything including passthrough
audit_level = "matched"

# Allow rules - checked after deny rules
[[allow]]
tool = "Read"
file_path_regex = "^/Users/korny/Dropbox/prj/.*"
file_path_exclude_regex = "\\.\\."  # Block path traversal

[[allow]]
tool = "Bash"
command_regex = "^cargo (build|test|check|clippy|fmt|run)"
command_exclude_regex = "&|;|\\||`|\\$\\("  # Block shell injection

[[allow]]
tool = "Task"
subagent_type = "codebase-analyzer"

# Deny rules - checked first (take precedence)
[[deny]]
tool = "Bash"
command_regex = "^rm .*-rf"

[[deny]]
tool = "Read"
file_path_regex = "\\.(env|secret)$"

# LLM delegation - when enabled, static deny decisions are sent to an LLM
# for a second opinion. The LLM can override the deny if it determines the
# operation is safe. Requires OPENAI_API_KEY environment variable.
[llm]
enabled = true
model = "gpt-4o-mini"
system_prompt = "..." # optional, uses a sensible default

Claude Code Setup

Add to .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/claude-code-permissions-hook run --config ~/.config/claude-code-permissions-hook.toml"
          }
        ]
      }
    ]
  }
}

Usage

Validate Configuration

cargo run -- validate --config example.toml

Run as Hook (reads JSON from stdin)

echo '<hook-input-json>' | cargo run -- run --config example.toml

Run Tests

See tests/ directory for integration tests and sample JSON inputs.

How It Works

flowchart TB
    Start@{shape: rounded, label: "Claude attempts<br/>tool use"}
    ReadInput[Read JSON from stdin]
    LoadConfig[Load & compile<br/>TOML config]
    LogUse[Log tool use<br/>to file]
    CheckDeny@{shape: diamond, label: "Deny rule<br/>matches?"}
    CheckAllow@{shape: diamond, label: "Allow rule<br/>matches?"}
    LlmEnabled@{shape: diamond, label: "LLM enabled?"}
    AskLlm[Ask LLM]
    LlmResult@{shape: diamond, label: "LLM says?"}
    OutputDeny[Output deny decision<br/>to stdout]
    OutputAllow[Output allow decision<br/>to stdout]
    NoOutput[No output<br/>passthrough to<br/>Claude Code]
    EndDeny@{shape: rounded, label: "Tool blocked"}
    EndAllow@{shape: rounded, label: "Tool permitted"}
    EndPass@{shape: rounded, label: "Normal permission<br/>flow"}

    Start --> ReadInput
    ReadInput --> LoadConfig
    LoadConfig --> LogUse
    LogUse --> CheckDeny

    CheckDeny -->|Yes| OutputDeny
    CheckDeny -->|No| CheckAllow

    CheckAllow -->|Yes| OutputAllow
    CheckAllow -->|No| LlmEnabled

    LlmEnabled -->|Yes| AskLlm
    LlmEnabled -->|No| NoOutput
    AskLlm --> LlmResult
    LlmResult -->|Allow| OutputAllow
    LlmResult -->|Deny / Error| NoOutput

    OutputDeny --> EndDeny
    OutputAllow --> EndAllow
    NoOutput --> EndPass

    classDef processStyle fill:#e0e7ff,stroke:#4338ca,color:#1e1b4b
    classDef decisionStyle fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef denyStyle fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
    classDef allowStyle fill:#d1fae5,stroke:#10b981,color:#064e3b
    classDef passStyle fill:#f3f4f6,stroke:#6b7280,color:#1f2937
    classDef startEndStyle fill:#ddd6fe,stroke:#7c3aed,color:#3b0764
    classDef llmStyle fill:#ede9fe,stroke:#7c3aed,color:#3b0764

    class ReadInput,LoadConfig,LogUse processStyle
    class CheckDeny,CheckAllow decisionStyle
    class OutputDeny,EndDeny denyStyle
    class OutputAllow,EndAllow allowStyle
    class NoOutput,EndPass passStyle
    class Start startEndStyle
    class LlmEnabled,LlmResult decisionStyle
    class AskLlm llmStyle
Loading

Deny rules are checked first (block), then allow rules (permit). When no rule matches and LLM delegation is enabled, the LLM decides whether to allow or deny. No match without LLM = passthrough to normal Claude Code permissions.

See the Configuration Guide for detailed rule syntax and security patterns.

Development

cargo test      # Run tests
cargo clippy    # Lint code
cargo fmt       # Format code

Logging

Audit file (JSON): Records tool use to audit_file. Set audit_level to off, matched (default), or all.

Diagnostic log (stderr): Set RUST_LOG=debug for rule matching debug output.

License

See LICENSE file for details.