Dyad uses Claude Code for automated PR reviews on its open-source GitHub repo. Running an AI coding agent on a real codebase means it will try to run shell commands, write files, and call the GitHub API. Some of those operations are fine. Others could delete a release, merge a PR, or push secrets. Claude Code hooks let you intercept every permission request and decide what happens next. Dyad's hook system takes this a step further: it uses Claude Sonnet itself to classify ambiguous requests that rule-based hooks cannot catch.
What are Claude Code hooks?
Claude Code hooks are scripts that run at specific points in the agent's lifecycle. You configure them in .claude/settings.json under the hooks key. Each hook fires on an event type and receives JSON on stdin describing what the agent wants to do.
Three event types matter here:
- PreToolUse: Fires before any tool call (Bash, Edit, Write). Your hook can allow, deny, or pass through to the normal permission prompt.
- PermissionRequest: Fires when a tool call would normally show the user a permission dialog. This is a fallback for anything the PreToolUse hooks did not handle.
- Stop: Fires when the agent finishes its task.
The hook outputs a JSON decision. If it outputs nothing and exits with code 0, Claude Code falls through to its default behavior (usually prompting the user).
Here is the relevant section from Dyad's .claude/settings.json:
Two fast, rule-based hooks run first on PreToolUse. If neither of them catches the request, and Claude Code would normally show a permission dialog, the PermissionRequest hook fires. That one calls Claude Sonnet.
The rule-based hooks
The first layer is deterministic. No LLM involved.
gh-permission-hook.py
This hook handles every gh CLI command. It parses the command string, extracts the subcommand, and classifies it:
- Allow: Read-only operations (
gh pr view,gh issue list,gh run view --log), PR workflow commands (gh pr create,gh pr review,gh pr comment), and safe GitHub API calls (GET requests, PR comment replies, review thread mutations). - Deny: Destructive operations like
gh repo delete,gh release create,gh secret set,gh workflow run, and any unrecognized GraphQL mutation. - Passthrough: Unrecognized
ghcommands fall through to the next hook or the normal permission prompt.
The hook also blocks shell injection. Before evaluating the gh command, it checks for shell metacharacters like ;, &&, `, $(), and process substitution. Safe patterns are exempted: pipes to text-processing commands like jq, grep, and sort are allowed. Single-quoted strings are stripped before checking because bash treats their contents as literal.
python-permission-hook.py
This hook restricts Python script execution. It allows scripts only from inside the .claude directory (where the hooks themselves live) and python -m pytest for running tests. Everything else, including -c inline code, -m with arbitrary modules, and interactive mode, is denied.
Both hooks share a pattern: parse the command, classify it against a known policy, and output a JSON decision. They run in under 5 seconds. If neither one matches (because the command is not a gh or python call), the request passes through.
The AI-powered fallback: permission-request-hook.py
The rule-based hooks cover gh and python commands. But Claude Code can also run arbitrary bash commands, write to files, and use other tools. A rule-based approach for every possible bash command would be enormous and fragile.
The PermissionRequest hook solves this differently. When a tool call reaches the permission dialog (meaning no earlier hook auto-approved or denied it), this hook calls Claude Sonnet to classify the operation.
Here is the core logic:
The hook loads a policy document (permission-policy.md), appends the current request details, and sends the whole thing to Claude Sonnet via the claude CLI in --print mode. Sonnet returns a JSON object with a score (GREEN, YELLOW, or RED) and a reason.
- GREEN: Auto-approve. The user never sees the permission dialog.
- YELLOW: Pass through. The user gets the normal permission dialog and decides.
- RED: Auto-deny. The request is blocked with an
[AI-RED]prefix in the denial message.
The --no-session-persistence flag is important. Without it, each hook invocation would create a persistent session, and since the hook calls Claude from within a Claude Code session, you would get recursive session creation.
The permission policy DSL
The policy document is a structured Markdown file that acts as a domain-specific language for security classification. It defines rules across three domains:
Bash commands: Read-only file operations, safe text processing, development workflow commands (npm, make, cargo), git operations (including force push and history rewriting), and file operations within the working directory are all GREEN. Package management with specific packages, network operations, and Docker are YELLOW. Destructive operations on system directories, direct .git directory manipulation, curl | bash, and cryptocurrency-related commands are RED.
GitHub operations: The same categories as the gh-permission-hook.py, but expressed in natural language for the LLM to interpret. This provides a second layer of defense for GitHub operations that might slip through the rule-based hook.
Edit/Write operations: Project source files and test files are GREEN. Shell scripts and CI/CD configuration are YELLOW. System files, dotfiles, and SSH keys are RED.
The policy also includes instructions for analyzing command chains. Instead of marking all piped or chained commands as dangerous, it tells the LLM to evaluate each command individually and use the most restrictive score.
Preventing infinite loops
Using Claude to supervise Claude raises an obvious concern: can the supervised agent trigger the supervisor, which triggers the supervised agent, and so on?
The architecture avoids this in three ways:
--printmode: The supervisor call runs Claude in print mode, not agent mode. It cannot use tools, so it cannot trigger tool-use hooks.--no-session-persistence: Each supervisor call is stateless. No session context accumulates.- Environment variable kill switch: Setting
DYAD_DISABLE_CLAUDE_CODE_HOOKS=truedisables the AI hook entirely. The hook checks this at the top ofmain()and exits immediately if set.
Why use an LLM to supervise an LLM?
The honest answer: rule-based hooks are better when you can write them. They are fast, deterministic, and testable. Dyad's gh-permission-hook.py is 627 lines of Python with comprehensive regex patterns and explicit allow/deny lists. It handles hundreds of gh command variations without any LLM call.
But rule-based hooks cannot cover the full surface area of what an AI coding agent might do. The space of possible bash commands is effectively infinite. An LLM-based fallback handles the long tail: unusual commands, edge cases, and novel tool uses that no one anticipated when writing the rules.
The trade-off is latency. The rule-based hooks run in milliseconds. The LLM hook takes up to 25 seconds (its timeout). That is why it only fires on PermissionRequest, not PreToolUse. It is a last resort, not a first check.
Setting this up in your own repo
The Dyad source code is on GitHub under the MIT license (with FSL 1.1 for pro features). The Claude Code hooks live in dyad/.claude/hooks/. You can use the same structure in any project where you run Claude Code.
To build your own Claude Code hooks:
- Create a
.claude/hooks/directory in your project. - Write Python scripts that read JSON from stdin and output JSON decisions to stdout.
- Write a
permission-policy.mdthat describes your security policy in structured Markdown. - Configure the hooks in
.claude/settings.jsonwith appropriate matchers and timeouts. - Start with rule-based hooks for the commands you can enumerate. Add the LLM fallback for everything else.
The key design principle: fast deterministic checks first, slow AI classification only when needed. Most requests should be handled by rule-based hooks in milliseconds. The LLM fallback catches what falls through.
Dyad is a free, open-source desktop app for building apps with AI. It runs locally on Mac, Windows, and Linux. You can download it at dyad.sh.