hooks-management
Manage hooks and automation for coding agents (Claude Code, Codex CLI, OpenCode). Use when users want to add, list, remove, update, or validate hooks. Triggers on requests like "add a hook", "create a hook that...", "list my hooks", "remove the hook", "validate hooks", or any men
Install
npx skills add https://github.com/CodeAlive-AI/ai-driven-development/tree/main/skills/hooks-management
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install codealive-ai-ai-driven-development@llmmart
git clone https://github.com/CodeAlive-AI/ai-driven-development.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole codealive-ai/ai-driven-development collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Hooks Management
Manage hooks and automation through natural language commands.
IMPORTANT: After adding, modifying, or removing hooks, always inform the user that they need to restart the agent for changes to take effect. Hooks are loaded at startup.
Quick Reference
Hook Events (Claude Code, as of 2026-04 — 28 events):
- Session lifecycle: SessionStart, SessionEnd, InstructionsLoaded
- User input: UserPromptSubmit, UserPromptExpansion
- Tool execution: PreToolUse, PostToolUse, PostToolUseFailure, PostToolBatch
- Permissions: PermissionRequest, PermissionDenied
- Model output: Stop, StopFailure
- Subagents/tasks: SubagentStart, SubagentStop, TaskCreated, TaskCompleted, TeammateIdle
- Config/state: ConfigChange, FileChanged, CwdChanged
- Compaction: PreCompact, PostCompact
- Worktree: WorktreeCreate, WorktreeRemove
- MCP: Elicitation, ElicitationResult
- Notifications: Notification
Handler types: command, http, mcp_tool, prompt, agent. Some events are command-only (PostCompact, PermissionDenied, Elicitation/ElicitationResult, FileChanged, CwdChanged, ConfigChange, InstructionsLoaded, WorktreeCreate/Remove, SubagentStart, StopFailure, TeammateIdle, Setup, SessionStart, SessionEnd, Notification).
Claude Code Settings Files:
- User-wide:
~/.claude/settings.json - Project:
.claude/settings.json - Local (not committed):
.claude/settings.local.json - Drop-in policy fragments:
~/.claude/managed-settings.d/(managed-settings only)
Codex CLI / Codex App Settings Files (current as of 2026-06):
- User config:
~/.codex/config.toml - User hooks:
~/.codex/hooks.jsonor inline[hooks]tables in~/.codex/config.toml - Project hooks:
<repo>/.codex/hooks.jsonor inline[hooks]tables in<repo>/.codex/config.toml(trusted projects only) - Codex App and CLI share these config layers. In the App/IDE, the settings UI opens the same
config.toml. - Hooks are enabled by default. Use
[features].hooks = falseto disable them.codex_hooksis a deprecated alias. - Non-managed Codex command hooks must be reviewed/trusted with
/hooks; changed hook definitions are skipped until trusted.
Devin CLI / Desktop hook locations:
- Project:
.devin/hooks.v1.json(standalone hooks object, recommended) or"hooks"in.devin/config.json/.devin/config.local.json - User:
"hooks"in~/.config/devin/config.json(%APPDATA%\devin\config.jsonon Windows) - Claude-format hooks under
.claude/are imported automatically whenread_config_from.claudeis on (default) - Events: PreToolUse, PostToolUse, PermissionRequest, UserPromptSubmit, Stop, PostCompaction, SessionStart, SessionEnd;
matcheris a regex ontool_name - See references/devin-hooks.md for the full event/output contract
Claude Code default control mechanism for PreToolUse: emit JSON on stdout with hookSpecificOutput.permissionDecision set to "allow", "deny", "ask" (triggers the built-in user confirmation prompt), or "defer" (pause headless tool calls; resume with -p --resume). See Decision Control. Do NOT roll your own confirmation schemes (env-var flags, interactive osascript prompts, bypass tokens) — those break the built-in UX and silently fail under existing permissions.allow entries.
Codex exception: Codex PreToolUse does not support "ask" yet. In Codex configs, use deny / exit code 2 for hard blocks, additionalContext for advisory context, or Codex approval policy/permissions for native prompts.
Disable all hooks: set disableAllHooks: true in settings.json.
Workflow
1. Understand the Request
Parse what the user wants:
- Add/Create: New hook for specific event and tool
- List/Show: Display current hooks configuration
- Remove/Delete: Remove specific hook(s)
- Update/Modify: Change existing hook
- Validate: Check hooks for errors
2. Validate Before Writing
Always run validation before saving:
python3 "$SKILL_PATH/scripts/validate_hooks.py" ~/.claude/settings.json
3. Read Current Configuration
cat ~/.claude/settings.json 2>/dev/null || echo '{}'
4. Apply Changes
Use Edit tool for modifications, Write tool for new files.
Adding Hooks
Translate Natural Language to Hook Config
| User Says | Event | Matcher | Notes |
|---|---|---|---|
| "log all bash commands" | PreToolUse | Bash | Logging to file |
| "format files after edit" | PostToolUse | Edit|Write | Run formatter |
| "block .env file changes" | PreToolUse | Edit|Write | Exit code 2 blocks |
| "notify me when done" | Notification | "" | Desktop notification |
| "run tests after code changes" | PostToolUse | Edit|Write | Filter by extension |
| "ask before dangerous commands" | PreToolUse | Bash | Claude Code: emit JSON permissionDecision: "ask" (built-in confirm UI). Codex: use approval policy if possible; hook-level ask is unsupported. |
| "require manual approval for X" | PreToolUse | Bash/Edit/Write | Claude Code: emit JSON permissionDecision: "ask", NOT exit 2. Codex: choose policy prompt or hard block. |
| "block unless confirmed" | PreToolUse | Bash | Claude Code: JSON "ask" lets the user approve per call. Codex: no hook-created confirmation prompt yet. |
Hook Configuration Template
{
"hooks": {
"EVENT_NAME": [
{
"matcher": "TOOL_PATTERN",
"hooks": [
{
"type": "command",
"command": "SHELL_COMMAND",
"timeout": 60
}
]
}
]
}
}
Simple vs Complex Hooks
PREFER SCRIPT FILES for complex hooks. Inline commands with nested quotes, osascript, or multi-step logic often break due to JSON escaping issues.
| Complexity | Approach | Example |
|---|---|---|
| Simple | Inline | jq -r '.tool_input.command' >> log.txt |
| Medium | Inline | Single grep/jq pipe with basic conditionals |
| Complex | Script file | Dialogs, multiple conditions, osascript, error handling |
Script location: ~/.claude/hooks/ (create if needed)
Script template for PreToolUse (~/.claude/hooks/my-hook.sh) — use JSON decision control as the primary mechanism; exit codes are a fallback for simple blocking only:
#!/bin/bash
set -euo pipefail
# Read JSON input from stdin
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command')
# Your logic here
if echo "$cmd" | grep -q 'pattern-requiring-confirmation'; then
# PRIMARY PATTERN for "require user confirmation": emit JSON on stdout.
# Claude Code will show its built-in confirm prompt to the user.
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "ask",
permissionDecisionReason: "Explain why this call is risky"
}
}'
exit 0
fi
if echo "$cmd" | grep -q 'pattern-to-hard-block'; then
# Hard block (no user override possible): JSON deny, NOT exit 2.
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Reason shown to Claude"
}
}'
exit 0
fi
exit 0 # Allow (silent)
Why JSON decisions, not exit 2 or home-grown prompts:
permissionDecision: "ask"triggers the built-in Claude Code confirm UI — the user sees a clean prompt and can allow/deny per-call.exit 2is a blunt block; the user cannot override it from the UI, and Claude often re-tries with workarounds.- Home-grown schemes (env-var flags like
CONFIRMED=1,osascriptdialogs, bypass tokens) break the native UX, leak into command history, and are silently bypassed if the tool already has a matchingpermissions.allowrule.
Hook config using script:
{
"type": "command",
"command": "~/.claude/hooks/my-hook.sh"
}
Other handler types (2026): http (POSTs event JSON to a URL), mcp_tool (calls a tool on a configured MCP server), prompt (evaluates a prompt with an LLM, supports $ARGUMENTS), agent (runs an agentic verifier with tools). Some events are command-only (PostCompact, PermissionDenied, Elicitation/ElicitationResult, FileChanged, CwdChanged, ConfigChange, InstructionsLoaded, WorktreeCreate/Remove, SubagentStart, StopFailure, TeammateIdle, SessionStart/End, Notification).
Always:
- Create script in
~/.claude/hooks/ - Make executable:
chmod +x ~/.claude/hooks/my-hook.sh - Test with sample input:
echo '{"tool_input":{"command":"test"}}' | ~/.claude/hooks/my-hook.sh
Common Patterns
Logging (PreToolUse):
{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "jq -r '.tool_input.command' >> ~/.claude/command-log.txt"
}]
}
File Protection (PreToolUse, exit 2 to block):
{
"matcher": "Edit|Write",
"hooks": [{
"type": "command",
"command": "jq -r '.tool_input.file_path' | grep -qE '(\\.env|secrets)' && exit 2 || exit 0"
}]
}
Auto-format (PostToolUse):
{
"matcher": "Edit|Write",
"hooks": [{
"type": "command",
"command": "file=$(jq -r '.tool_input.file_path'); [[ $file == *.ts ]] && npx prettier --write \"$file\" || true"
}]
}
Desktop Notification (Notification):
{
"matcher": "",
"hooks": [{
"type": "command",
"command": "osascript -e 'display notification \"Claude needs attention\" with title \"Claude Code\"'"
}]
}
Decision Control (Claude Code PreToolUse)
Claude Code PreToolUse hooks control tool execution by emitting JSON on stdout. This is the default mechanism — use it instead of exit codes whenever the intent is richer than "silently allow / hard block", especially when the user should be asked to confirm.
permissionDecision |
Behavior | Use for |
|---|---|---|
"allow" |
Bypass permissions, proceed silently | Pre-approving a safe call |
"deny" |
Block, reason shown to Claude | Hard block (no user override) |
"ask" |
Built-in Claude Code confirm UI shown to user | "Require manual approval for X" — the canonical pattern |
"defer" |
Pause headless tool call, resume via -p --resume |
External-system integrations in headless (-p) sessions |
Additional JSON fields:
permissionDecisionReason— shown to the user for"allow"/"ask", shown to Claude for"deny"updatedInput— modify tool input before executionadditionalContext— inject context for Claude before the tool executes
Ask user before dangerous command (the canonical pattern)
When the user says anything like "require manual confirmation", "ask before doing X", "don't run Y without my approval" — this is the pattern. Do not invent bypass env vars, osascript dialogs, or confirmation tokens. The built-in prompt already handles per-call allow/deny and is the only path that integrates with existing permissions.allow rules correctly.
#!/bin/bash
set -euo pipefail
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // empty')
if echo "$cmd" | grep -qE 'supabase\s+db\s+reset'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "ask",
permissionDecisionReason: "This will destroy and recreate the local database."
}
}'
else
exit 0
fi
Deny with reason (hard block)
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Destructive command blocked by hook"
}
}'
Gotcha: "ask" vs existing permissions.allow rules
If the tool call already matches an entry in .claude/settings.local.json → permissions.allow (for example, "Bash" is blanket-allowed for this session), the hook's "ask" is bypassed and the call proceeds silently. Symptom: the hook appears to do nothing. Diagnose by reading .claude/settings.local.json and narrowing the allow rule, or remove the blanket allow for the matcher while the hook is in effect.
See references/claude-event-schemas.md for the full output schema.
Codex CLI / Codex App Hooks
As of June 2026, Codex hooks are enabled by default and are shared by Codex CLI, Codex IDE extension, and Codex App/desktop sessions through the same ~/.codex and trusted project .codex configuration layers.
Current Codex lifecycle events:
SessionStartSubagentStartPreToolUsePermissionRequestPostToolUsePreCompactPostCompactUserPromptSubmitSubagentStopStop
Do not add the old feature flag for new configs. If hooks must be disabled, use:
[features]
hooks = false
Minimal PreToolUse blocking hook:
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = '/usr/bin/python3 ~/.codex/hooks/policy.py'
timeout = 30
statusMessage = "Checking Bash command"
Equivalent ~/.codex/hooks.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "^Bash$",
"hooks": [
{
"type": "command",
"command": "/usr/bin/python3 ~/.codex/hooks/policy.py",
"timeout": 30,
"statusMessage": "Checking Bash command"
}
]
}
]
}
}
Prefer one representation per config layer: either hooks.json or inline [hooks]. Codex loads both and warns if both exist in the same layer.
Blocking semantics: exit code 2 blocks (stderr is reason), or emit JSON {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "..."}}.
Important Codex gap: PreToolUse currently does not support permissionDecision: "ask". Returning "ask" makes the hook run fail and Codex continues the tool call. For fail-closed behavior, map Claude-style ask to Codex deny.
When adapting a Claude Code ask hook to Codex, prefer this order:
- Use hard
deny/ exit 2 when the command must not run without review. - If the risky action can be expressed as command argv prefixes and the user explicitly accepts best-effort native Codex prompts, use Codex rules for the prompt and keep the hook silent for that exact reason code.
- Do not put the whole hook into shadow mode unless you intentionally want logging only; that allows every risky action the hook would otherwise catch.
For bash-guard specifically, Codex live mode defaults to hard deny for internal ask decisions. Only reason codes listed in BASH_GUARD_CODEX_DEFER_REASON_CODES are allowed to pass through to execpolicy, and installers only add that env var when the user passes --codex-native-prompts. Each deferred reason code must have a matching prefix_rule(... decision="prompt"); otherwise the risky command would be silently allowed.
PermissionRequest only fires when Codex is already about to ask for approval. It can return allow, deny, or no decision; it cannot create a prompt for commands that Codex would otherwise run without asking.
Codex PreToolUse can intercept Bash, apply_patch file edits, and MCP tool calls, but it is not a complete enforcement boundary: interception of richer shell paths is incomplete, and WebSearch / non-shell / non-MCP tool calls are out of scope.
See references/codex-hooks.md for full Codex hooks reference, all event input/output schemas, common patterns, and migration from the legacy AfterAgent / AfterToolUse events.
OpenCode Hooks (Plugin-based)
OpenCode (anomalyco/opencode v1.14.x) does NOT use config-based shell hooks. Hooks are TypeScript/JavaScript plugins that subscribe to lifecycle events. The closest analogue to PreToolUse is tool.execute.before — throwing inside it blocks the tool call.
// .opencode/plugins/env-protection.ts
import type { Plugin } from "@opencode-ai/plugin"
export default (async () => ({
tool: {
execute: {
before: async (input, output) => {
if (output.args.filePath?.includes(".env")) {
throw new Error("Reading .env is forbidden")
}
},
},
},
})) satisfies Plugin
Plugin locations:
- Project:
.opencode/plugins/*.ts - Global:
~/.config/opencode/plugins/*.ts - npm packages: listed in
opencode.jsonunderplugin: []
Common events: tool.execute.before, tool.execute.after, session.idle, session.created, file.edited, permission.asked, command.executed (~25 total).
Critical caveat (v1.14.x): tool.execute.* hooks do NOT fire for MCP tool calls — use the permission block in opencode.json to control MCP tool access instead.
For "ask before" semantics, prefer permission rules over plugin throws — they integrate with the built-in confirm UI:
{ "permission": { "bash": { "rm -rf *": "ask" } } }
See references/opencode-hooks.md for the full event catalog, migration patterns from Claude Code hooks, and npm plugin distribution.
Event Input Schemas
See references/claude-event-schemas.md for complete JSON input schemas for each event type (Claude Code).
Validation
Run validation script to check hooks:
python3 "$SKILL_PATH/scripts/validate_hooks.py" <settings-file>
Validates:
- JSON syntax
- Required fields (type, command/prompt)
- Valid event names
- Matcher patterns (regex validity)
- Command syntax basics
Removing Hooks
- Read current config
- Identify hook by event + matcher + command pattern
- Remove from hooks array
- If array empty, remove the matcher entry
- If event empty, remove event key
- Validate and save
Exit Codes
| Code | Meaning | Use Case |
|---|---|---|
| 0 | Success/Allow | Continue execution |
| 2 | Block | Simple blocking (prefer JSON decision control for PreToolUse) |
| Other | Error | Log to stderr, shown in verbose mode |
Security Checklist
Before adding hooks, verify:
- No credential logging
- No sensitive data exposure
- Specific matchers (avoid
*when possible) - Validated input parsing
- Appropriate timeout for long operations
Troubleshooting
Hook not triggering: Check matcher case-sensitivity, ensure event name is exact.
Command failing: Test command standalone with sample JSON input.
Permission denied: Ensure script is executable (chmod +x).
Timeout: Increase timeout field or optimize command.
Files (ai-driven-development)
-
references
-
claude-event-schemas.md 10.7 KB
# Event Input Schemas Complete JSON schemas for each hook event type. Hooks receive this data via stdin. **As of 2026-04** Claude Code supports 28 lifecycle events (was 14 in 2025). New events added in the last 12 months: `PostCompact`, `PostToolUseFailure`, `PostToolBatch`, `PermissionDenied`, `StopFailure`, `SubagentStart`, `TaskCreated`, `TaskCompleted`, `TeammateIdle`, `ConfigChange`, `FileChanged`, `CwdChanged`, `WorktreeCreate`, `WorktreeRemove`, `Elicitation`, `ElicitationResult`, `InstructionsLoaded`, `UserPromptExpansion`. Handler types expanded from `command` to `command | http | mcp_tool | prompt | agent`. ## Common Fields (All Events) ```json { "session_id": "string", "transcript_path": "string", "cwd": "string", "permission_mode": "default|plan|acceptEdits|auto|dontAsk|bypassPermissions", "hook_event_name": "string" } ``` `auto` is the Sonnet/Opus-based classifier permission mode (Team/Enterprise/Max plans). When auto denies a tool call, the `PermissionDenied` hook fires. ## PreToolUse Runs before tool execution. Exit 2 to block. ```json { "hook_event_name": "PreToolUse", "tool_name": "Bash|Edit|Write|Read|...", "tool_input": { /* tool-specific */ }, "tool_use_id": "string" } ``` ### Tool Input by Tool **Bash**: ```json { "command": "string", "description": "string", "timeout": 120000, "run_in_background": false } ``` **Write**: ```json { "file_path": "string", "content": "string" } ``` **Edit**: ```json { "file_path": "string", "old_string": "string", "new_string": "string", "replace_all": false } ``` **Read**: ```json { "file_path": "string", "offset": 0, "limit": 0 } ``` ## PostToolUse Runs after tool execution completes successfully. ```json { "hook_event_name": "PostToolUse", "tool_name": "string", "tool_input": { /* tool-specific */ }, "tool_response": { /* response data */ }, "tool_use_id": "string", "duration_ms": 12 } ``` `duration_ms` (added in 2026) — tool execution time, excluding permission prompts and PreToolUse hooks. PostToolUse output may include `updatedMCPToolOutput` to replace MCP tool results before Claude sees them. ## PostToolUseFailure Runs when a tool fails. Has `error` and `is_interrupt`. ```json { "hook_event_name": "PostToolUseFailure", "tool_name": "string", "tool_input": {}, "tool_use_id": "string", "error": "string", "is_interrupt": false, "duration_ms": 0 } ``` ## PostToolBatch Runs after a full batch of parallel tool calls resolves, before the next model call. No matcher. ```json { "hook_event_name": "PostToolBatch", "tool_calls": [ /* array of executed tool info */ ] } ``` ## PermissionRequest Runs when permission dialog shown. Return JSON to allow/deny. ```json { "hook_event_name": "PermissionRequest", "tool_name": "string", "tool_input": { /* tool-specific */ }, "permission_suggestions": [] } ``` **Output to allow** (with optional `updatedPermissions` to persist a rule): ```json { "hookSpecificOutput": { "hookEventName": "PermissionRequest", "decision": { "behavior": "allow" }, "updatedInput": { /* optional, modified tool input */ }, "updatedPermissions": [ { "type": "addRules", "rules": [{ "toolName": "Bash", "ruleContent": "npm *" }], "behavior": "allow", "destination": "localSettings" } ] } } ``` **Output to deny**: ```json { "hookSpecificOutput": { "hookEventName": "PermissionRequest", "decision": { "behavior": "deny", "message": "Reason for denial", "interrupt": false } } } ``` ## PermissionDenied Fires after the auto-mode classifier (`permission_mode: "auto"`) denies a tool call. Return `retry: true` to tell the model it may retry. ```json { "hook_event_name": "PermissionDenied", "tool_name": "string", "tool_input": {}, "denial_reason": "string" } ``` **Output**: ```json { "hookSpecificOutput": { "hookEventName": "PermissionDenied", "retry": true } } ``` ## UserPromptSubmit Runs when user submits a prompt, before Claude processes it. ```json { "hook_event_name": "UserPromptSubmit", "prompt": "string" } ``` **Output** can include `decision: "block"` with `reason`, `additionalContext`, and `sessionTitle` to set/update the session title. ## UserPromptExpansion Runs when a slash command (or MCP prompt) expands into a prompt before reaching Claude. Matcher: `command_name`. ```json { "hook_event_name": "UserPromptExpansion", "expansion_type": "slash_command|mcp_prompt", "command_name": "string", "command_args": "string", "command_source": "user|project|plugin", "prompt": "string" } ``` Can block expansion or inject `additionalContext`. ## Notification Runs when Claude sends notifications. ```json { "hook_event_name": "Notification", "message": "string", "notification_type": "permission_prompt|idle_prompt|auth_success|elicitation_dialog" } ``` ## Stop / SubagentStop Runs when Claude (or a subagent) finishes responding. ```json { "hook_event_name": "Stop", "stop_hook_active": boolean } ``` **SubagentStop** also includes `agent_type`, `agent_id`, and `stop_reason`. ## StopFailure Fires when the turn ends due to API error. Output and exit code are ignored. ```json { "hook_event_name": "StopFailure", "error_type": "rate_limit|authentication_failed|billing_error|invalid_request|server_error|max_output_tokens|unknown", "error_message": "string", "retry_after": 0 } ``` ## SubagentStart Fires when a subagent is spawned. Matcher: agent type (`Bash`, `Explore`, `Plan`, custom). Observability only. ```json { "hook_event_name": "SubagentStart", "agent_type": "string", "agent_prompt": "string", "agent_model": "string" } ``` ## TaskCreated / TaskCompleted Fires when a task is created (`TaskCreate` tool) or marked complete. Both can block via `decision: "block"`. ```json { "hook_event_name": "TaskCreated", "task_name": "string", "task_description": "string" } { "hook_event_name": "TaskCompleted", "task_id": "string", "task_name": "string" } ``` ## TeammateIdle Fires when an agent-team teammate is about to go idle. Exit 2 or `continue: false` prevents idle. ## PreCompact Runs before compaction. Matcher: `"manual"` or `"auto"`. Can block via exit 2 or `{"decision":"block"}`. ```json { "hook_event_name": "PreCompact", "trigger": "manual|auto", "custom_instructions": "string" } ``` ## PostCompact Fires after compaction completes. Matcher: `"manual"` or `"auto"`. Observability only. ```json { "hook_event_name": "PostCompact", "trigger_reason": "manual|auto", "tokens_removed": 0, "compact_summary": "string" } ``` ## SessionStart Runs on session start/resume. Matcher: `"startup"|"resume"|"clear"|"compact"`. Special: Has access to `CLAUDE_ENV_FILE` env var for persisting variables. Handler types: `command`, `mcp_tool` only. ```json { "hook_event_name": "SessionStart", "source": "startup|resume|clear|compact", "model": "claude-sonnet-4-6" } ``` Output may include `additionalContext`. ## SessionEnd Runs when session ends. Matcher: `"clear"|"resume"|"logout"|"prompt_input_exit"|"bypass_permissions_disabled"|"other"`. ```json { "hook_event_name": "SessionEnd", "reason": "clear|logout|prompt_input_exit|bypass_permissions_disabled|other" } ``` ## InstructionsLoaded Fires when a `CLAUDE.md` or `.claude/rules/*.md` file loads into context. Matcher: `"session_start"|"nested_traversal"|"path_glob_match"|"include"|"compact"`. Observability only. ```json { "hook_event_name": "InstructionsLoaded", "file_path": "string", "memory_type": "string", "load_reason": "string", "globs": [], "trigger_file_path": "string", "parent_file_path": "string" } ``` ## ConfigChange Fires when a configuration source changes mid-session. Matcher: `"user_settings"|"project_settings"|"local_settings"|"policy_settings"|"skills"`. Can block (except `policy_settings`). ```json { "hook_event_name": "ConfigChange", "config_source": "string", "changes": {} } ``` ## FileChanged Fires when a watched file changes on disk. Matcher: literal filenames, alternation supported (`.envrc|.env`). Has `CLAUDE_ENV_FILE` access. ```json { "hook_event_name": "FileChanged", "file_path": "string", "change_type": "created|modified|deleted" } ``` ## CwdChanged Fires when the working directory changes. No matcher. Has `CLAUDE_ENV_FILE` access (useful with direnv). ```json { "hook_event_name": "CwdChanged", "old_cwd": "string", "new_cwd": "string" } ``` ## WorktreeCreate Fires when a worktree is being created via `--worktree` or subagent `isolation: "worktree"`. Handler types: `command`, `http`, `mcp_tool`. Non-zero exit aborts creation. ```json { "hook_event_name": "WorktreeCreate", "worktree_path": "string", "parent_path": "string", "isolation_reason": "string" } ``` `command` hook prints the chosen path on stdout; `http` hook returns `hookSpecificOutput.worktreePath`. ## WorktreeRemove Fires when a worktree is removed (session exit or subagent finish). Observability only. ```json { "hook_event_name": "WorktreeRemove", "worktree_path": "string", "removal_reason": "string" } ``` ## Elicitation / ElicitationResult `Elicitation` fires when an MCP server requests user input mid-tool-call. `ElicitationResult` fires after the user responds, before the response is sent back. Matcher: MCP server name. ```json { "hook_event_name": "Elicitation", "server_name": "string", "tool_name": "string", "elicitation_form": {} } ``` ```json { "hook_event_name": "ElicitationResult", "server_name": "string", "tool_name": "string", "user_response": {}, "form_fields": {} } ``` Both can override via `hookSpecificOutput.action: "accept"|"decline"|"cancel"` and `hookSpecificOutput.content`. ## Output Schema ### Standard Output ```json { "continue": true, "stopReason": "string", "suppressOutput": true, "systemMessage": "string", "hookSpecificOutput": { /* event-specific */ } } ``` ### PreToolUse Output ```json { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow|deny|ask|defer", "permissionDecisionReason": "string", "updatedInput": { /* modified tool input */ }, "additionalContext": "string" } } ``` `"defer"` (added 2026): pauses headless tool calls; resume with `claude -p --resume` to re-evaluate the hook. Returns `stop_reason: "tool_deferred"` with `deferred_tool_use` payload. ### PostToolUse Output ```json { "decision": "block", "reason": "string", "hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": "string" } } ``` ## Environment Variables Available in all hooks: - `CLAUDE_PROJECT_DIR`: Project root path - `CLAUDE_CODE_REMOTE`: `"true"` if remote session SessionStart only: - `CLAUDE_ENV_FILE`: File path for persisting env vars -
claude-templates.md 5.9 KB
# Hook Templates Ready-to-use hook configurations. Copy and adapt as needed. ## Logging & Auditing ### Log All Commands ```json { "hooks": { "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "jq -r '\"[\" + (now | strftime(\"%Y-%m-%d %H:%M:%S\")) + \"] \" + .tool_input.command' >> ~/.claude/command-log.txt" }] }] } } ``` ### Log File Changes ```json { "hooks": { "PostToolUse": [{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "jq -r '.tool_input.file_path' >> ~/.claude/modified-files.txt" }] }] } } ``` ## File Protection ### Block Sensitive Files Blocks edits to .env, secrets, credentials files. ```json { "hooks": { "PreToolUse": [{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "jq -r '.tool_input.file_path' | grep -qE '(\\.env|secrets|credentials|\\.pem|\\.key)' && { echo 'Protected file' >&2; exit 2; } || exit 0" }] }] } } ``` ### Block Git Directory ```json { "hooks": { "PreToolUse": [{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "jq -r '.tool_input.file_path' | grep -q '\\.git/' && { echo 'Cannot modify .git directory' >&2; exit 2; } || exit 0" }] }] } } ``` ### Block package-lock.json ```json { "hooks": { "PreToolUse": [{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "jq -r '.tool_input.file_path' | grep -q 'package-lock.json$' && { echo 'Let npm manage package-lock.json' >&2; exit 2; } || exit 0" }] }] } } ``` ## Code Formatting ### Auto-format TypeScript/JavaScript ```json { "hooks": { "PostToolUse": [{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "file=$(jq -r '.tool_input.file_path'); [[ $file =~ \\.(ts|tsx|js|jsx)$ ]] && npx prettier --write \"$file\" 2>/dev/null || true" }] }] } } ``` ### Auto-format Python ```json { "hooks": { "PostToolUse": [{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "file=$(jq -r '.tool_input.file_path'); [[ $file == *.py ]] && black --quiet \"$file\" 2>/dev/null || true" }] }] } } ``` ### Auto-format Go ```json { "hooks": { "PostToolUse": [{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "file=$(jq -r '.tool_input.file_path'); [[ $file == *.go ]] && gofmt -w \"$file\" 2>/dev/null || true" }] }] } } ``` ## Notifications ### macOS Notification ```json { "hooks": { "Notification": [{ "matcher": "", "hooks": [{ "type": "command", "command": "msg=$(jq -r '.message'); osascript -e \"display notification \\\"$msg\\\" with title \\\"Claude Code\\\"\"" }] }] } } ``` ### Linux Notification (notify-send) ```json { "hooks": { "Notification": [{ "matcher": "", "hooks": [{ "type": "command", "command": "jq -r '.message' | xargs -I {} notify-send 'Claude Code' '{}'" }] }] } } ``` ### Play Sound on Complete ```json { "hooks": { "Stop": [{ "matcher": "", "hooks": [{ "type": "command", "command": "afplay /System/Library/Sounds/Glass.aiff 2>/dev/null || true" }] }] } } ``` ## Command Safety ### Confirm Dangerous Commands Blocks rm -rf, git push --force, etc. without confirmation. ```json { "hooks": { "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "cmd=$(jq -r '.tool_input.command'); echo \"$cmd\" | grep -qE '(rm -rf|git push.*--force|DROP TABLE|DELETE FROM.*WHERE 1)' && { echo 'Dangerous command blocked' >&2; exit 2; } || exit 0" }] }] } } ``` ### Block sudo Commands ```json { "hooks": { "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "jq -r '.tool_input.command' | grep -q '^sudo ' && { echo 'sudo not allowed' >&2; exit 2; } || exit 0" }] }] } } ``` ## Testing & Quality ### Run Tests After Code Change ```json { "hooks": { "PostToolUse": [{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "file=$(jq -r '.tool_input.file_path'); [[ $file == *.test.* || $file == *_test.* ]] && npm test -- --findRelatedTests \"$file\" 2>/dev/null || true", "timeout": 120 }] }] } } ``` ### Lint on Save ```json { "hooks": { "PostToolUse": [{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "file=$(jq -r '.tool_input.file_path'); [[ $file =~ \\.(ts|tsx|js|jsx)$ ]] && npx eslint --fix \"$file\" 2>/dev/null || true" }] }] } } ``` ## Session Management ### Load Project Environment ```json { "hooks": { "SessionStart": [{ "matcher": "startup", "hooks": [{ "type": "command", "command": "[ -f \"$CLAUDE_PROJECT_DIR/.claude-env\" ] && cat \"$CLAUDE_PROJECT_DIR/.claude-env\" >> \"$CLAUDE_ENV_FILE\" || true" }] }] } } ``` ### Session Start Message ```json { "hooks": { "SessionStart": [{ "matcher": "startup|resume", "hooks": [{ "type": "command", "command": "echo '{\"systemMessage\": \"Project: '\"$CLAUDE_PROJECT_DIR\"'\"}'" }] }] } } ``` ## MCP Tools ### Log MCP Tool Usage ```json { "hooks": { "PreToolUse": [{ "matcher": "mcp__.*", "hooks": [{ "type": "command", "command": "jq -r '.tool_name' >> ~/.claude/mcp-usage.txt" }] }] } } ``` ### Block Specific MCP Server ```json { "hooks": { "PreToolUse": [{ "matcher": "mcp__dangerous_server__.*", "hooks": [{ "type": "command", "command": "echo 'MCP server blocked' >&2; exit 2" }] }] } } ``` -
codex-hooks.md 14.2 KB
# Codex CLI / Codex App Hooks Reference Hook support in [OpenAI Codex](https://github.com/openai/codex). Codex CLI, Codex IDE extension, and Codex App share the same configuration layers under `~/.codex` and trusted project `.codex` directories. ## Contents - [Current State](#current-state) - [Enabling Hooks](#enabling-hooks) - [Hook Events](#hook-events) - [Configuration Format](#configuration-format) - [Matchers](#matchers) - [Blocking and Decision Control](#blocking-and-decision-control) - [Hook Input Schema](#hook-input-schema) - [Hook Output Schema](#hook-output-schema) - [Common Patterns](#common-patterns) - [Notify Setting](#notify-setting) - [Rules vs Hooks](#rules-vs-hooks) - [Comparison with Claude Code](#comparison-with-claude-code) - [Migration from earlier Codex versions](#migration-from-earlier-codex-versions) - [Trust Requirements](#trust-requirements) ## Current State As of **2026-06**, lifecycle hooks are part of current Codex builds and are enabled by default. Codex supports ten lifecycle events and can intercept Bash, `apply_patch` file edits, and MCP tool calls. What changed in 2026: - **Feb 2026 (v0.117.0)**: PreToolUse + PostToolUse landed (originally only `SessionStart` and `Stop` existed). - **Mar 2026 (PR #14626)**: `UserPromptSubmit` hook added. - **Apr 2026 (v0.124.0)**: Hooks promoted to stable. Inline `[hooks.*]` tables in `config.toml` and `requirements.toml` are now supported in addition to `hooks.json`. - **May/Jun 2026**: Canonical feature key became `[features].hooks`; `[features].codex_hooks` remains only as a deprecated alias. Non-managed command hooks require explicit review/trust through `/hooks`. ## Enabling Hooks Hooks are enabled by default. To disable them: ```toml [features] hooks = false ``` Use `hooks` as the canonical feature key. `codex_hooks` still works as a deprecated alias in current builds but should not be written by new tooling. Codex App / IDE and CLI share these config layers. The App settings UI opens the same `~/.codex/config.toml`. ## Hook Events | Event | Scope | When it fires | |-------|-------|---------------| | `SessionStart` | session | Session initialization or resume | | `SubagentStart` | subagent | Subagent starts | | `PreToolUse` | turn | Before a tool runs (can block) | | `PermissionRequest` | turn | When Codex is already about to ask for approval | | `PostToolUse` | turn | After tool completes | | `PreCompact` | turn | Before compaction | | `PostCompact` | turn | After compaction | | `UserPromptSubmit` | turn | User submits a prompt (can block) | | `SubagentStop` | subagent | Subagent is about to stop; can request continuation | | `Stop` | turn | When the agent's turn ends | **`PreToolUse` interception scope:** Bash commands, file edits via `apply_patch`, and MCP tool calls. It is a guardrail — Codex may still accomplish equivalent work via another tool path, so do not treat hooks as a complete enforcement boundary. ## Configuration Format Hooks live inline in `config.toml` or in a sibling `hooks.json`. Do **not** mix both representations in the same config layer — Codex loads both and warns. ### Inline TOML ```toml # PreToolUse: gate Bash commands [[hooks.PreToolUse]] matcher = "^Bash$" [[hooks.PreToolUse.hooks]] type = "command" command = '/usr/bin/python3 "$(git rev-parse --show-toplevel)/.codex/hooks/pre_tool_use_policy.py"' timeout = 30 # seconds; default 600 statusMessage = "Checking Bash command" # PostToolUse: review Bash output [[hooks.PostToolUse]] matcher = "^Bash$" [[hooks.PostToolUse.hooks]] type = "command" command = '/usr/bin/python3 "$(git rev-parse --show-toplevel)/.codex/hooks/post_tool_use_review.py"' timeout = 30 statusMessage = "Reviewing Bash output" ``` ### hooks.json ```json { "hooks": { "PreToolUse": [ { "matcher": "^Bash$", "hooks": [ { "type": "command", "command": "/usr/bin/python3 ~/.codex/hooks/pre_tool_use_policy.py", "timeout": 30, "statusMessage": "Checking Bash command" } ] } ] } } ``` ### Enterprise-managed hooks `requirements.toml` (admin-controlled) supports the same `[hooks.*]` blocks plus managed hook sources. Managed hooks are trusted by policy and cannot be disabled from the user hook browser. ```toml [hooks] managed_dir = "/enterprise/hooks" windows_managed_dir = 'C:\enterprise\hooks' [[hooks.PreToolUse]] matcher = "^Bash$" [[hooks.PreToolUse.hooks]] type = "command" command = "python3 /enterprise/hooks/pre_tool_use_policy.py" ``` ### Concurrency When the same event matches in multiple config layers (user, project, requirements), **all matching hooks run** — higher-precedence layers do not replace lower ones. Multiple hooks for the same event run **concurrently**; one cannot prevent another from starting. ### Discovery and trust Useful locations: - `~/.codex/hooks.json` - `~/.codex/config.toml` - `<repo>/.codex/hooks.json` - `<repo>/.codex/config.toml` Project-local hooks load only when the project `.codex/` layer is trusted. Non-managed command hooks must also be reviewed in `/hooks`; Codex records trust by hook definition hash, so changed hooks are skipped until reviewed again. ## Matchers The `matcher` field is a regex matched against `tool_name` and tool aliases. - `matcher = "^Bash$"` — only Bash - `matcher = ""`, `matcher = "*"`, or omit `matcher` — every event - `matcher = "apply_patch|Edit|Write"` — file edits via `apply_patch` (the alias also accepts `Edit` and `Write` for parity with Claude Code; `tool_name` in the input is still `apply_patch`) - `matcher = "mcp__github__.*"` — all tools from a specific MCP server ## Blocking and Decision Control Codex offers **two blocking mechanisms** for `PreToolUse`: exit code 2 (simple) and JSON `permissionDecision: "deny"` (rich). ### Exit code semantics | Exit code | Meaning | |-----------|---------| | `0` with no output | Allow, continue silently | | `2` | Block; `stderr` is shown to Codex as the reason | | Other | Logged as error in verbose mode; non-blocking | ### JSON output (richer control) PreToolUse and PermissionRequest accept a `hookSpecificOutput` JSON envelope. PostToolUse, UserPromptSubmit, and Stop accept a top-level decision object. **PreToolUse — block with reason:** ```json { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Destructive command blocked." } } ``` `PreToolUse` does **not** support `permissionDecision: "ask"` as of June 2026. If a hook returns `"ask"`, Codex marks that hook run as failed, reports the error, and continues the tool call. Use Codex approval policy/permissions for native prompts; otherwise choose between hard-blocking (`deny` / exit 2) and advisory context. **PermissionRequest — deny with user-facing message:** ```json { "hookSpecificOutput": { "hookEventName": "PermissionRequest", "decision": { "behavior": "deny", "message": "Blocked by repository policy." } } } ``` **UserPromptSubmit — refuse a prompt:** ```json { "decision": "block", "reason": "Ask for confirmation before doing that." } ``` ### Common output fields All events accept these top-level keys: | Key | Type | Effect | |-----|------|--------| | `continue` | boolean | Event-dependent; unsupported on PreToolUse / PermissionRequest | | `stopReason` | string | Why we stopped (paired with `continue: false`) | | `systemMessage` | string | Injected as a system note (PostToolUse only fully supports it) | | `suppressOutput` | boolean | Hide hook stdout from the user (PostToolUse parses but does not currently honor) | > **Capability gaps as of 2026-06:** `permissionDecision: "ask"`, `continue`, `stopReason`, and `suppressOutput` are **not** supported for PreToolUse. PermissionRequest can allow, deny, or decline to decide, but it only fires when Codex was already about to ask. ## Hook Input Schema Every event receives JSON on stdin with these common fields: | Field | Type | Description | |-------|------|-------------| | `session_id` | string | Conversation/session ID | | `transcript_path` | string | Path to the running transcript | | `cwd` | string | Working directory | | `hook_event_name` | string | Event name (`PreToolUse`, etc.) | | `model` | string | Model in use this turn | ### Per-event additions | Event | Extra fields | |-------|--------------| | `SessionStart` | `source` in `startup`, `resume`, `clear`, `compact` | | `SubagentStart` | `turn_id`, `agent_id`, `agent_type`, `permission_mode` | | `UserPromptSubmit` | `turn_id`, `prompt` | | `PreToolUse` | `turn_id`, `tool_name`, `tool_use_id`, `tool_input` | | `PermissionRequest` | `turn_id`, `tool_name`, `tool_input` (incl. `description`) | | `PostToolUse` | `turn_id`, `tool_name`, `tool_use_id`, `tool_input`, `tool_response` | | `PreCompact` / `PostCompact` | `turn_id`, `trigger` (`manual` or `auto`) | | `SubagentStop` | `turn_id`, `agent_id`, `agent_type`, `agent_transcript_path`, `stop_hook_active`, `last_assistant_message` | | `Stop` | `turn_id`, `stop_hook_active`, `last_assistant_message` | ## Hook Output Schema The complete output schema lives in [docs](https://developers.openai.com/codex/hooks). Quick reference: ```json { "continue": true, "stopReason": "optional reason", "systemMessage": "optional banner", "suppressOutput": false, "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow|deny", "permissionDecisionReason": "string" } } ``` ## Common Patterns ### Block destructive commands ```bash #!/usr/bin/env python3 # .codex/hooks/pre_tool_use_policy.py import json, sys inp = json.load(sys.stdin) cmd = " ".join(inp.get("tool_input", {}).get("command", [])) if "rm -rf" in cmd or "sudo " in cmd: print(json.dumps({ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Destructive command blocked by hook." } })) sys.exit(0) sys.exit(0) ``` ### Scan for credentials in user prompts ```bash #!/usr/bin/env python3 # .codex/hooks/scan_prompt.py import json, sys, re inp = json.load(sys.stdin) prompt = inp.get("prompt", "") if re.search(r"sk-[a-zA-Z0-9]{20,}|AKIA[0-9A-Z]{16}", prompt): print(json.dumps({ "decision": "block", "reason": "API key or AWS access key detected in prompt." })) sys.exit(0) sys.exit(0) ``` ### Log every shell command (PostToolUse, fire-and-forget) ```toml [[hooks.PostToolUse]] matcher = "^Bash$" [[hooks.PostToolUse.hooks]] type = "command" command = 'jq -r ".tool_input.command | join(\" \")" >> ~/.codex/command-log.txt' ``` ### Auto-format after `apply_patch` ```toml [[hooks.PostToolUse]] matcher = "apply_patch" [[hooks.PostToolUse.hooks]] type = "command" command = '~/.codex/hooks/format_changed.sh' timeout = 60 ``` ## Notify Setting Independent of `[hooks]`. Trigger an external program on lifecycle events (still useful for desktop notifications): ```toml notify = ["notify-send", "Codex"] # Linux notify = ["bash", "-lc", "afplay /System/Library/Sounds/Blow.aiff"] # macOS [tui] notifications = ["agent-turn-complete", "approval-requested"] # Filter ``` ## Rules vs Hooks Starlark rules in `.codex/rules/` and `~/.codex/rules/` are still useful — they fire **before** the model decides whether to invoke a tool, are cheap, and integrate with smart-approval learning: ```starlark prefix_rule( pattern = ["rm", ["-rf", "-r"]], decision = "forbidden", justification = "Use git clean -fd instead.", ) ``` Rules cover **command policy** (allow / prompt / forbidden). Hooks cover **scripted automation**: logging, secret scanning, formatting, custom validators. Use both: rules for static policy, hooks for dynamic checks and side effects. ## Comparison with Claude Code | Feature | Claude Code | Codex CLI / App (2026-06) | |---------|------------|--------------------| | **Total events** | 28+ | 10 | | **PreToolUse blocking** | Full (exit 2 or JSON) | Supports `deny` / exit 2; hook-created `ask` unsupported | | **PostToolUse** | Full | Full | | **PreToolUse `updatedInput`** | Yes | Yes for supported tool inputs when paired with `permissionDecision: "allow"` | | **`additionalContext` injection** | Yes | Yes for supported events | | **PermissionRequest event** | Equivalent | Yes | | **UserPromptSubmit** | Yes | Yes | | **SessionStart / Stop** | Yes | Yes | | **SubagentStart / SubagentStop** | Yes | Yes | | **PreCompact / PostCompact** | Yes | Yes | | **SessionEnd / Notification** | Yes | Not currently | | **MCP tool interception** | Yes | Yes (in `PreToolUse`) | | **File edit interception** | Yes (Edit/Write) | Yes (`apply_patch`) | | **Concurrent hooks** | Yes | Yes | | **Config format** | JSON (`settings.json`) | TOML (`[hooks.*]` in `config.toml`) or `hooks.json` | | **Config locations** | `~/.claude/settings.json`, `.claude/settings.json` | `~/.codex/config.toml`, `~/.codex/hooks.json`, project `.codex/*`, managed config | | **Trust gating** | None for user scope | Project `.codex` must be trusted; non-managed command hooks require `/hooks` review | ## Migration from earlier Codex versions If you previously used `AfterAgent` / `AfterToolUse` (the original fire-and-forget events), migrate to the stable equivalents: | Legacy event | Replace with | |--------------|--------------| | `AfterAgent` | `Stop` | | `AfterToolUse` | `PostToolUse` | Legacy fields: - `hook_event` → `hook_event_name` - `thread_id` → `session_id` - `triggered_at` → not provided; compute in your hook if needed - Argv-style payload → all events now read JSON from stdin ## Trust Requirements - **User-level hooks** (`~/.codex/config.toml`, `~/.codex/hooks.json`): always loaded. - **Project-level hooks** (`.codex/config.toml`, `.codex/hooks.json`): loaded **only** for trusted projects. Use `codex trust` (or accept the trust prompt) to enable. - **Managed hooks** (`requirements.toml` + `managed_dir`): always loaded; not user-overridable. ## Sources - [Codex hooks docs](https://developers.openai.com/codex/hooks) - [Codex configuration reference](https://developers.openai.com/codex/config-reference) - [Codex changelog](https://developers.openai.com/codex/changelog) - [Codex Desktop hook regression report — openai/codex#21639](https://github.com/openai/codex/issues/21639) -
devin-hooks.md 2.6 KB
# Devin CLI hooks Devin CLI/Desktop hooks are JSON-configured and fire on lifecycle events. The format is close to Claude Code's but not identical — read the event table before porting a hook. ## Where hooks live Project level (discovered in the working directory and ancestors up to the repository root): | File | Format | |------|--------| | `.devin/hooks.v1.json` | Standalone hooks file — the whole file is the hooks object (recommended) | | `.devin/config.json` | `"hooks"` key | | `.devin/config.local.json` | `"hooks"` key (gitignored) | | `.claude/settings.json` / `.claude/settings.local.json` | `"hooks"` key — Claude Code format is picked up automatically | User level: | File | Format | |------|--------| | `~/.config/devin/config.json` (`%APPDATA%\devin\config.json` on Windows) | `"hooks"` key | | `~/.claude.json`, `~/.claude/settings.json`, `~/.claude/settings.local.json` | `"hooks"` key (imported) | ## Events | Event | Fires | |-------|-------| | `PreToolUse` | Before a tool executes (can rewrite input via `updatedInput`) | | `PostToolUse` | After a tool finishes | | `PermissionRequest` | When a permission decision is needed | | `UserPromptSubmit` | On user message submit (`additionalContext` injects context) | | `Stop` | When the agent wants to stop (can block to force follow-up) | | `PostCompaction` | After context compaction completes | | `SessionStart` | Session begin (no `prompt_id` yet) | | `SessionEnd` | Session end | ## Hook entry format ```json { "PreToolUse": [ { "matcher": "Exec", "hooks": [ { "type": "command", "command": "./scripts/check-command.sh" } ] } ] } ``` - `matcher` — regex against the event's `tool_name`; empty/omitted matches all. - `type` — `command` (runs a shell command) or `prompt`. - Command hooks get event JSON on **stdin**; every payload carries `session_id` (stable) and `prompt_id` (rotated per user prompt, absent before the first prompt). - Exit code `0` = ok, non-zero = block the action. ## Stdout control contract Print a JSON object to stdout to influence the run: | Field | Effect | |-------|--------| | `hookSpecificOutput.hookEventName` | Event the output applies to | | `hookSpecificOutput.additionalContext` | Injected into context (`UserPromptSubmit`, `SessionStart`, `PostToolUse`) | | `hookSpecificOutput.updatedInput` | Merged into tool args before execution (`PreToolUse` only) | ## Notes - `Stop` hooks that block can loop the agent — ensure the condition converges. - `.local.` in any config filename marks a gitignored personal override. - Changes require a session restart to take effect. -
opencode-hooks.md 10.4 KB
# OpenCode Hooks Reference Hook/lifecycle event support in [anomalyco/opencode](https://github.com/anomalyco/opencode) (v1.14.x). OpenCode does **not** have a config-based shell hooks system like Claude Code. Instead, hooks are TypeScript/JavaScript **plugins** that subscribe to lifecycle events and can intercept/block tool calls. ## Contents - [Architecture](#architecture) - [Plugin Locations](#plugin-locations) - [Plugin Structure](#plugin-structure) - [Hook Events](#hook-events) - [Tool Interception (`tool.execute.before` / `after`)](#tool-interception-toolexecutebefore--after) - [Common Patterns](#common-patterns) - [npm Plugins](#npm-plugins) - [Limitations](#limitations) - [Comparison with Claude Code](#comparison-with-claude-code) ## Architecture | Aspect | Detail | |--------|--------| | Language | TypeScript or JavaScript | | Runtime | Bun (bundled with OpenCode) | | SDK package | `@opencode-ai/plugin` (npm) | | Loading | Auto-discovery from plugin directories + npm packages listed in `opencode.json` | | Persistence | Plugins run inside the OpenCode process — full SDK access | Install the SDK locally for type completion: ```bash npm i -D @opencode-ai/plugin # or bun add -d @opencode-ai/plugin ``` ## Plugin Locations | Scope | Path | |-------|------| | Project | `<project>/.opencode/plugins/` (or `.opencode/plugin/`) | | Global | `~/.config/opencode/plugins/` | | npm | Listed in `opencode.json` under `plugin` | Files: `.ts`, `.js`, `.mjs`. Each file's default export is loaded; named exports starting with `default` are also picked up. For project-local plugins that need npm packages, add a `package.json` in `.opencode/`. OpenCode runs `bun install` at startup. Cached node_modules live in `~/.cache/opencode/node_modules/`. ## Plugin Structure ```typescript // .opencode/plugins/my-plugin.ts import type { Plugin } from "@opencode-ai/plugin" export const MyPlugin: Plugin = async ({ project, client, $, directory, worktree, app }) => { return { // Tool lifecycle hooks (cannot block MCP calls in v1.14.x) tool: { execute: { before: async (input, output) => { if (input.tool === "read" && output.args.filePath?.includes(".env")) { throw new Error("Reading .env files is forbidden") } }, after: async (input, output) => { if (input.tool === "edit" || input.tool === "write") { await $`prettier --write ${output.args.filePath}`.quiet() } }, }, }, // Generic event subscription event: async ({ event }) => { if (event.type === "session.idle") { client.app.log({ level: "info", message: "Session idle" }) } }, } } export default MyPlugin ``` ### Plugin context (`PluginInput`) | Field | Description | |-------|-------------| | `project` | Project metadata (root, name) | | `client` | OpenCode SDK client (`createOpencodeClient` instance) | | `$` | Bun shell helper (`@bun/shell`-style) | | `directory` | CWD where OpenCode was invoked | | `worktree` | Git worktree root | | `app` | App-level helpers (logging, etc.) | ## Hook Events OpenCode's plugin hooks cover **25+ lifecycle events** grouped by domain: ### Tool - `tool.execute.before` — fires before any tool call (except MCP, see Limitations) - `tool.execute.after` — fires after tool completes ### Session - `session.created` - `session.updated` - `session.idle` — turn complete - `session.compacted` - `session.deleted` - `session.diff` - `session.error` - `session.status` ### Message - `message.part.updated` - `message.part.removed` - `message.updated` - `message.removed` ### File - `file.edited` - `file.watcher.updated` ### Permission - `permission.asked` - `permission.replied` ### Command / Todo / LSP / TUI / Server - `command.executed` - `todo.updated` - `lsp.client.diagnostics` - `lsp.updated` - `tui.prompt.append` - `tui.command.execute` - `tui.toast.show` - `server.connected` - `installation.updated` - `shell.env` ### Experimental - `experimental.session.compacting` — inject context or rewrite prompt during compaction ## Tool Interception (`tool.execute.before` / `after`) The closest analogue to Claude Code's `PreToolUse` is `tool.execute.before`. **Throwing inside `before` blocks the tool call.** ```typescript tool: { execute: { before: async (input, output) => { // input.tool — tool name string // output.args — tool arguments (may be mutated to modify input) // Block .env reads if (input.tool === "read" && output.args.filePath?.includes(".env")) { throw new Error("Reading .env is forbidden") } // Block destructive bash if (input.tool === "bash" && /rm\s+-rf|git\s+push.*--force/.test(output.args.command || "")) { throw new Error("Destructive command blocked by hook") } // Modify args before execution if (input.tool === "bash") { output.args.command = output.args.command.replace(/cd /, "cd ./") } }, }, } ``` ### `apply_patch` quirks For `apply_patch`, the file path lives inside `output.args.patchText` as a marker line, not in `output.args.filePath`. Markers include: - `*** Add File: <path>` - `*** Update File: <path>` - `*** Move to: <path>` - `*** Delete File: <path>` Paths are relative to the project root. ## Common Patterns ### Block .env access (file protection) ```typescript import type { Plugin } from "@opencode-ai/plugin" export default (async () => ({ tool: { execute: { before: async (input, output) => { const path = output.args.filePath ?? "" if (/\.env(\.|$)|secrets|credentials|\.pem$|\.key$/i.test(path)) { throw new Error(`Protected file: ${path}`) } }, }, }, })) satisfies Plugin ``` Save as `.opencode/plugins/env-protection.ts`. Loaded automatically. ### Auto-format on edit ```typescript export default (async ({ $ }) => ({ tool: { execute: { after: async (input, output) => { if (input.tool !== "edit" && input.tool !== "write") return const file = output.args.filePath if (!file) return if (/\.(ts|tsx|js|jsx)$/.test(file)) await $`prettier --write ${file}`.quiet() else if (file.endsWith(".py")) await $`black --quiet ${file}`.quiet() else if (file.endsWith(".go")) await $`gofmt -w ${file}`.quiet() }, }, }, })) satisfies Plugin ``` ### Session-end notification ```typescript export default (async ({ $ }) => ({ event: async ({ event }) => { if (event.type === "session.idle") { // macOS await $`osascript -e 'display notification "Session idle" with title "OpenCode"'`.quiet() } }, })) satisfies Plugin ``` ### Log all bash commands ```typescript export default (async () => ({ tool: { execute: { before: async (input, output) => { if (input.tool !== "bash") return const fs = await import("node:fs/promises") await fs.appendFile( `${process.env.HOME}/.config/opencode/command-log.txt`, `[${new Date().toISOString()}] ${output.args.command}\n`, ) }, }, }, })) satisfies Plugin ``` ## npm Plugins Distribute via npm with the `opencode-plugin` keyword. Register in `opencode.json`: ```json { "plugin": [ "opencode-helicone-session", "opencode-wakatime", "@my-org/custom-plugin" ] } ``` OpenCode runs `bun install` at startup, caches under `~/.cache/opencode/node_modules/`. `package.json` for an authored plugin: ```json { "name": "opencode-my-plugin", "version": "1.0.0", "main": "dist/index.js", "keywords": ["opencode-plugin"], "peerDependencies": { "@opencode-ai/plugin": "^1.0.0" } } ``` ## Limitations | Limitation | Impact | |------------|--------| | **MCP tool calls do NOT trigger `tool.execute.before/after`** in v1.14.x | Plugin-based interception of MCP tools is impossible — use the `permission` block instead | | No "agent-as-hook" pattern | Cannot spawn an analyzer subagent before tool use the way Claude Code's `"type": "agent"` hooks can | | No `Notification` equivalent for OS-level notifications without manual shelling | Use `$` from the plugin context to call `osascript` / `notify-send` yourself | | No session-start hook that blocks startup | `session.created` fires after init | | Plugin failures fail loudly | Throwing in `before` blocks the tool; throwing elsewhere may surface as a session error | ## Comparison with Claude Code | Capability | Claude Code | OpenCode | |-----------|-------------|----------| | Pre-tool blocking | `PreToolUse` hook with exit 2 / JSON `permissionDecision: "deny"` | `tool.execute.before` plugin throws | | Pre-tool ask user | `PreToolUse` JSON `permissionDecision: "ask"` | Set `permission` rule to `"ask"`; plugin can mutate args before | | Post-tool side effects | `PostToolUse` shell command | `tool.execute.after` plugin | | Notifications | `Notification` event + shell hook | Subscribe to `session.idle` etc. in plugin, shell out via `$` | | Session lifecycle | `SessionStart`, `Stop`, `SubagentStop` | `session.created`, `session.idle`, `session.deleted` | | Block on MCP tool | `mcp__server__.*` matcher | **Not supported** in v1.14.x — use `permission` | | Modify tool input | `updatedInput` in JSON output | Mutate `output.args` in `tool.execute.before` | | Execution model | Shell command (stdin/stdout JSON) | Async TypeScript function with full SDK | | Distribution | Hooks live in `settings.json` or plugins | npm packages or local TS files | ### Migration map | Claude Code hook | OpenCode equivalent | |------------------|---------------------| | `PreToolUse` Bash exit 2 | `tool.execute.before` throws | | `PreToolUse` permissionDecision `"ask"` | `permission` rule `"ask"` | | `PreToolUse` permissionDecision `"deny"` | `permission` rule `"deny"` (or throw) | | `PostToolUse` formatter | `tool.execute.after` runs `$`prettier ...`` | | `Notification` osascript | `event` listener for `session.idle` | | `SessionStart` env loader | `session.created` event | | `Stop` cleanup | `session.idle` (turn end) or `session.deleted` | | File protection on `.env` | `tool.execute.before` rejects on path match | ## Sources - https://opencode.ai/docs/plugins/ - https://opencode.ai/docs/tools/ - https://www.npmjs.com/package/@opencode-ai/plugin - https://github.com/anomalyco/opencode/issues/2319 (MCP-hook caveat) - https://dev.to/einarcesar/does-opencode-support-hooks-a-complete-guide-to-extensibility-k3p - https://lushbinary.com/blog/opencode-plugin-development-custom-tools-hooks-guide/
-
-
scripts
-
validate_hooks.py 6.8 KB
#!/usr/bin/env python3 """ Validate Claude Code hooks configuration. Usage: python3 validate_hooks.py <settings-file> python3 validate_hooks.py ~/.claude/settings.json Validates: - JSON syntax - Required fields - Valid event names - Matcher patterns (regex validity) - Hook type and required type-specific fields Exit codes: 0: Valid configuration 1: Invalid configuration (errors printed to stderr) """ import json import re import sys from pathlib import Path from typing import Any VALID_EVENTS = { "PreToolUse", "PostToolUse", "PermissionRequest", "UserPromptSubmit", "Notification", "Stop", "SubagentStop", "PreCompact", "SessionStart", "SessionEnd", } # Events that require/support matchers MATCHER_EVENTS = { "PreToolUse", "PostToolUse", "PermissionRequest", "Notification", "PreCompact", "SessionStart", } VALID_HOOK_TYPES = {"command", "prompt"} # Events that support prompt hooks PROMPT_SUPPORTED_EVENTS = { "PreToolUse", "PermissionRequest", "UserPromptSubmit", "Stop", "SubagentStop", } def validate_regex(pattern: str) -> tuple[bool, str | None]: """Validate a regex pattern.""" if pattern in ("*", ""): return True, None try: re.compile(pattern) return True, None except re.error as e: return False, str(e) def validate_hook(hook: dict[str, Any], event: str, path: str) -> list[str]: """Validate a single hook configuration.""" errors = [] # Check type field if "type" not in hook: errors.append(f"{path}: missing required field 'type'") return errors hook_type = hook["type"] if hook_type not in VALID_HOOK_TYPES: errors.append(f"{path}: invalid type '{hook_type}', must be one of {VALID_HOOK_TYPES}") return errors # Check type-specific required fields if hook_type == "command": if "command" not in hook: errors.append(f"{path}: type 'command' requires 'command' field") elif not isinstance(hook["command"], str): errors.append(f"{path}: 'command' must be a string") elif not hook["command"].strip(): errors.append(f"{path}: 'command' cannot be empty") elif hook_type == "prompt": if event not in PROMPT_SUPPORTED_EVENTS: errors.append(f"{path}: prompt hooks not supported for event '{event}'") if "prompt" not in hook: errors.append(f"{path}: type 'prompt' requires 'prompt' field") elif not isinstance(hook["prompt"], str): errors.append(f"{path}: 'prompt' must be a string") elif not hook["prompt"].strip(): errors.append(f"{path}: 'prompt' cannot be empty") # Check timeout if present if "timeout" in hook: timeout = hook["timeout"] if not isinstance(timeout, (int, float)): errors.append(f"{path}: 'timeout' must be a number") elif timeout <= 0: errors.append(f"{path}: 'timeout' must be positive") # Check once if present if "once" in hook and not isinstance(hook["once"], bool): errors.append(f"{path}: 'once' must be a boolean") return errors def validate_matcher_entry(entry: dict[str, Any], event: str, idx: int) -> list[str]: """Validate a matcher entry (contains matcher and hooks array).""" errors = [] path = f"hooks.{event}[{idx}]" # Check matcher if event supports it if event in MATCHER_EVENTS: if "matcher" in entry: matcher = entry["matcher"] if not isinstance(matcher, str): errors.append(f"{path}.matcher: must be a string") else: valid, regex_err = validate_regex(matcher) if not valid: errors.append(f"{path}.matcher: invalid regex pattern '{matcher}': {regex_err}") # Check hooks array if "hooks" not in entry: errors.append(f"{path}: missing required field 'hooks'") return errors hooks = entry["hooks"] if not isinstance(hooks, list): errors.append(f"{path}.hooks: must be an array") return errors if not hooks: errors.append(f"{path}.hooks: array cannot be empty") return errors for i, hook in enumerate(hooks): if not isinstance(hook, dict): errors.append(f"{path}.hooks[{i}]: must be an object") continue errors.extend(validate_hook(hook, event, f"{path}.hooks[{i}]")) return errors def validate_hooks_config(config: dict[str, Any]) -> list[str]: """Validate the hooks configuration.""" errors = [] if "hooks" not in config: return [] # No hooks configured, valid hooks = config["hooks"] if not isinstance(hooks, dict): errors.append("hooks: must be an object") return errors for event, entries in hooks.items(): # Check event name if event not in VALID_EVENTS: errors.append(f"hooks.{event}: invalid event name, must be one of {sorted(VALID_EVENTS)}") continue # Check entries array if not isinstance(entries, list): errors.append(f"hooks.{event}: must be an array") continue for i, entry in enumerate(entries): if not isinstance(entry, dict): errors.append(f"hooks.{event}[{i}]: must be an object") continue errors.extend(validate_matcher_entry(entry, event, i)) return errors def validate_file(filepath: str) -> tuple[bool, list[str]]: """Validate a settings file containing hooks.""" path = Path(filepath) errors = [] if not path.exists(): return False, [f"File not found: {filepath}"] try: content = path.read_text() except Exception as e: return False, [f"Could not read file: {e}"] # Handle empty file if not content.strip(): return True, [] # Empty file is valid (no hooks) try: config = json.loads(content) except json.JSONDecodeError as e: return False, [f"Invalid JSON: {e}"] if not isinstance(config, dict): return False, ["Configuration must be a JSON object"] errors = validate_hooks_config(config) return len(errors) == 0, errors def main(): if len(sys.argv) < 2: print("Usage: validate_hooks.py <settings-file>", file=sys.stderr) print("Example: validate_hooks.py ~/.claude/settings.json", file=sys.stderr) sys.exit(1) filepath = sys.argv[1] valid, errors = validate_file(filepath) if valid: print(f"✓ Valid hooks configuration: {filepath}") sys.exit(0) else: print(f"✗ Invalid hooks configuration: {filepath}", file=sys.stderr) for error in errors: print(f" - {error}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main()
-
-
SKILL.md 18.6 KB
--- name: hooks-management description: Manage hooks and automation for coding agents (Claude Code, Codex CLI, OpenCode, Devin CLI/Desktop). Use when users want to add, list, remove, update, or validate hooks. Triggers on requests like "add a hook", "create a hook that...", "list my hooks", "remove the hook", "validate hooks", or any mention of automating agent behavior with shell commands or plugins. --- # Hooks Management Manage hooks and automation through natural language commands. **IMPORTANT**: After adding, modifying, or removing hooks, always inform the user that they need to **restart the agent** for changes to take effect. Hooks are loaded at startup. ## Quick Reference **Hook Events** (Claude Code, as of 2026-04 — 28 events): - *Session lifecycle*: SessionStart, SessionEnd, InstructionsLoaded - *User input*: UserPromptSubmit, UserPromptExpansion - *Tool execution*: PreToolUse, PostToolUse, PostToolUseFailure, PostToolBatch - *Permissions*: PermissionRequest, PermissionDenied - *Model output*: Stop, StopFailure - *Subagents/tasks*: SubagentStart, SubagentStop, TaskCreated, TaskCompleted, TeammateIdle - *Config/state*: ConfigChange, FileChanged, CwdChanged - *Compaction*: PreCompact, PostCompact - *Worktree*: WorktreeCreate, WorktreeRemove - *MCP*: Elicitation, ElicitationResult - *Notifications*: Notification **Handler types**: `command`, `http`, `mcp_tool`, `prompt`, `agent`. Some events are command-only (PostCompact, PermissionDenied, Elicitation/ElicitationResult, FileChanged, CwdChanged, ConfigChange, InstructionsLoaded, WorktreeCreate/Remove, SubagentStart, StopFailure, TeammateIdle, Setup, SessionStart, SessionEnd, Notification). **Claude Code Settings Files**: - User-wide: `~/.claude/settings.json` - Project: `.claude/settings.json` - Local (not committed): `.claude/settings.local.json` - Drop-in policy fragments: `~/.claude/managed-settings.d/` (managed-settings only) **Codex CLI / Codex App Settings Files (current as of 2026-06)**: - User config: `~/.codex/config.toml` - User hooks: `~/.codex/hooks.json` or inline `[hooks]` tables in `~/.codex/config.toml` - Project hooks: `<repo>/.codex/hooks.json` or inline `[hooks]` tables in `<repo>/.codex/config.toml` (trusted projects only) - Codex App and CLI share these config layers. In the App/IDE, the settings UI opens the same `config.toml`. - Hooks are enabled by default. Use `[features].hooks = false` to disable them. `codex_hooks` is a deprecated alias. - Non-managed Codex command hooks must be reviewed/trusted with `/hooks`; changed hook definitions are skipped until trusted. **Devin CLI / Desktop hook locations**: - Project: `.devin/hooks.v1.json` (standalone hooks object, recommended) or `"hooks"` in `.devin/config.json` / `.devin/config.local.json` - User: `"hooks"` in `~/.config/devin/config.json` (`%APPDATA%\devin\config.json` on Windows) - Claude-format hooks under `.claude/` are imported automatically when `read_config_from.claude` is on (default) - Events: PreToolUse, PostToolUse, PermissionRequest, UserPromptSubmit, Stop, PostCompaction, SessionStart, SessionEnd; `matcher` is a regex on `tool_name` - See [references/devin-hooks.md](references/devin-hooks.md) for the full event/output contract **Claude Code default control mechanism for PreToolUse**: emit JSON on stdout with `hookSpecificOutput.permissionDecision` set to `"allow"`, `"deny"`, **`"ask"`** (triggers the built-in user confirmation prompt), or **`"defer"`** (pause headless tool calls; resume with `-p --resume`). See [Decision Control](#decision-control-pretooluse). Do NOT roll your own confirmation schemes (env-var flags, interactive `osascript` prompts, bypass tokens) — those break the built-in UX and silently fail under existing `permissions.allow` entries. **Codex exception**: Codex `PreToolUse` does not support `"ask"` yet. In Codex configs, use `deny` / exit code 2 for hard blocks, `additionalContext` for advisory context, or Codex approval policy/permissions for native prompts. **Disable all hooks**: set `disableAllHooks: true` in settings.json. ## Workflow ### 1. Understand the Request Parse what the user wants: - **Add/Create**: New hook for specific event and tool - **List/Show**: Display current hooks configuration - **Remove/Delete**: Remove specific hook(s) - **Update/Modify**: Change existing hook - **Validate**: Check hooks for errors ### 2. Validate Before Writing Always run validation before saving: ```bash python3 "$SKILL_PATH/scripts/validate_hooks.py" ~/.claude/settings.json ``` ### 3. Read Current Configuration ```bash cat ~/.claude/settings.json 2>/dev/null || echo '{}' ``` ### 4. Apply Changes Use Edit tool for modifications, Write tool for new files. ## Adding Hooks ### Translate Natural Language to Hook Config | User Says | Event | Matcher | Notes | |-----------|-------|---------|-------| | "log all bash commands" | PreToolUse | Bash | Logging to file | | "format files after edit" | PostToolUse | Edit\|Write | Run formatter | | "block .env file changes" | PreToolUse | Edit\|Write | Exit code 2 blocks | | "notify me when done" | Notification | "" | Desktop notification | | "run tests after code changes" | PostToolUse | Edit\|Write | Filter by extension | | "ask before dangerous commands" | PreToolUse | Bash | Claude Code: emit JSON `permissionDecision: "ask"` (built-in confirm UI). Codex: use approval policy if possible; hook-level `ask` is unsupported. | | "require manual approval for X" | PreToolUse | Bash/Edit/Write | Claude Code: emit JSON `permissionDecision: "ask"`, NOT exit 2. Codex: choose policy prompt or hard block. | | "block unless confirmed" | PreToolUse | Bash | Claude Code: JSON `"ask"` lets the user approve per call. Codex: no hook-created confirmation prompt yet. | ### Hook Configuration Template ```json { "hooks": { "EVENT_NAME": [ { "matcher": "TOOL_PATTERN", "hooks": [ { "type": "command", "command": "SHELL_COMMAND", "timeout": 60 } ] } ] } } ``` ### Simple vs Complex Hooks **PREFER SCRIPT FILES** for complex hooks. Inline commands with nested quotes, `osascript`, or multi-step logic often break due to JSON escaping issues. | Complexity | Approach | Example | |------------|----------|---------| | Simple | Inline | `jq -r '.tool_input.command' >> log.txt` | | Medium | Inline | Single grep/jq pipe with basic conditionals | | Complex | **Script file** | Dialogs, multiple conditions, osascript, error handling | **Script location**: `~/.claude/hooks/` (create if needed) **Script template for PreToolUse** (`~/.claude/hooks/my-hook.sh`) — use JSON decision control as the primary mechanism; exit codes are a fallback for simple blocking only: ```bash #!/bin/bash set -euo pipefail # Read JSON input from stdin input=$(cat) cmd=$(echo "$input" | jq -r '.tool_input.command') # Your logic here if echo "$cmd" | grep -q 'pattern-requiring-confirmation'; then # PRIMARY PATTERN for "require user confirmation": emit JSON on stdout. # Claude Code will show its built-in confirm prompt to the user. jq -n '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "ask", permissionDecisionReason: "Explain why this call is risky" } }' exit 0 fi if echo "$cmd" | grep -q 'pattern-to-hard-block'; then # Hard block (no user override possible): JSON deny, NOT exit 2. jq -n '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Reason shown to Claude" } }' exit 0 fi exit 0 # Allow (silent) ``` **Why JSON decisions, not exit 2 or home-grown prompts:** - `permissionDecision: "ask"` triggers the built-in Claude Code confirm UI — the user sees a clean prompt and can allow/deny per-call. - `exit 2` is a blunt block; the user cannot override it from the UI, and Claude often re-tries with workarounds. - Home-grown schemes (env-var flags like `CONFIRMED=1`, `osascript` dialogs, bypass tokens) break the native UX, leak into command history, and are silently bypassed if the tool already has a matching `permissions.allow` rule. **Hook config using script**: ```json { "type": "command", "command": "~/.claude/hooks/my-hook.sh" } ``` **Other handler types (2026)**: `http` (POSTs event JSON to a URL), `mcp_tool` (calls a tool on a configured MCP server), `prompt` (evaluates a prompt with an LLM, supports `$ARGUMENTS`), `agent` (runs an agentic verifier with tools). Some events are command-only (PostCompact, PermissionDenied, Elicitation/ElicitationResult, FileChanged, CwdChanged, ConfigChange, InstructionsLoaded, WorktreeCreate/Remove, SubagentStart, StopFailure, TeammateIdle, SessionStart/End, Notification). **Always**: 1. Create script in `~/.claude/hooks/` 2. Make executable: `chmod +x ~/.claude/hooks/my-hook.sh` 3. Test with sample input: `echo '{"tool_input":{"command":"test"}}' | ~/.claude/hooks/my-hook.sh` ### Common Patterns **Logging (PreToolUse)**: ```json { "matcher": "Bash", "hooks": [{ "type": "command", "command": "jq -r '.tool_input.command' >> ~/.claude/command-log.txt" }] } ``` **File Protection (PreToolUse, exit 2 to block)**: ```json { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "jq -r '.tool_input.file_path' | grep -qE '(\\.env|secrets)' && exit 2 || exit 0" }] } ``` **Auto-format (PostToolUse)**: ```json { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "file=$(jq -r '.tool_input.file_path'); [[ $file == *.ts ]] && npx prettier --write \"$file\" || true" }] } ``` **Desktop Notification (Notification)**: ```json { "matcher": "", "hooks": [{ "type": "command", "command": "osascript -e 'display notification \"Claude needs attention\" with title \"Claude Code\"'" }] } ``` ## Decision Control (Claude Code PreToolUse) Claude Code PreToolUse hooks control tool execution by emitting JSON on stdout. This is the **default mechanism** — use it instead of exit codes whenever the intent is richer than "silently allow / hard block", especially when the user should be asked to confirm. | `permissionDecision` | Behavior | Use for | |----------------------|----------|---------| | `"allow"` | Bypass permissions, proceed silently | Pre-approving a safe call | | `"deny"` | Block, reason shown to Claude | Hard block (no user override) | | `"ask"` | **Built-in Claude Code confirm UI** shown to user | "Require manual approval for X" — the canonical pattern | | `"defer"` | Pause headless tool call, resume via `-p --resume` | External-system integrations in headless (`-p`) sessions | Additional JSON fields: - `permissionDecisionReason` — shown to the user for `"allow"`/`"ask"`, shown to Claude for `"deny"` - `updatedInput` — modify tool input before execution - `additionalContext` — inject context for Claude before the tool executes ### Ask user before dangerous command (the canonical pattern) When the user says anything like **"require manual confirmation"**, **"ask before doing X"**, **"don't run Y without my approval"** — this is the pattern. Do not invent bypass env vars, `osascript` dialogs, or confirmation tokens. The built-in prompt already handles per-call allow/deny and is the only path that integrates with existing `permissions.allow` rules correctly. ```bash #!/bin/bash set -euo pipefail input=$(cat) cmd=$(echo "$input" | jq -r '.tool_input.command // empty') if echo "$cmd" | grep -qE 'supabase\s+db\s+reset'; then jq -n '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "ask", permissionDecisionReason: "This will destroy and recreate the local database." } }' else exit 0 fi ``` ### Deny with reason (hard block) ```bash jq -n '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Destructive command blocked by hook" } }' ``` ### Gotcha: `"ask"` vs existing `permissions.allow` rules If the tool call already matches an entry in `.claude/settings.local.json` → `permissions.allow` (for example, `"Bash"` is blanket-allowed for this session), the hook's `"ask"` is **bypassed** and the call proceeds silently. Symptom: the hook appears to do nothing. Diagnose by reading `.claude/settings.local.json` and narrowing the allow rule, or remove the blanket allow for the matcher while the hook is in effect. See [references/claude-event-schemas.md](references/claude-event-schemas.md) for the full output schema. ## Codex CLI / Codex App Hooks As of June 2026, Codex hooks are enabled by default and are shared by Codex CLI, Codex IDE extension, and Codex App/desktop sessions through the same `~/.codex` and trusted project `.codex` configuration layers. Current Codex lifecycle events: - `SessionStart` - `SubagentStart` - `PreToolUse` - `PermissionRequest` - `PostToolUse` - `PreCompact` - `PostCompact` - `UserPromptSubmit` - `SubagentStop` - `Stop` Do not add the old feature flag for new configs. If hooks must be disabled, use: ```toml [features] hooks = false ``` Minimal PreToolUse blocking hook: ```toml [[hooks.PreToolUse]] matcher = "^Bash$" [[hooks.PreToolUse.hooks]] type = "command" command = '/usr/bin/python3 ~/.codex/hooks/policy.py' timeout = 30 statusMessage = "Checking Bash command" ``` Equivalent `~/.codex/hooks.json`: ```json { "hooks": { "PreToolUse": [ { "matcher": "^Bash$", "hooks": [ { "type": "command", "command": "/usr/bin/python3 ~/.codex/hooks/policy.py", "timeout": 30, "statusMessage": "Checking Bash command" } ] } ] } } ``` Prefer one representation per config layer: either `hooks.json` or inline `[hooks]`. Codex loads both and warns if both exist in the same layer. Blocking semantics: exit code `2` blocks (stderr is reason), or emit JSON `{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "..."}}`. Important Codex gap: `PreToolUse` currently does **not** support `permissionDecision: "ask"`. Returning `"ask"` makes the hook run fail and Codex continues the tool call. For fail-closed behavior, map Claude-style `ask` to Codex `deny`. When adapting a Claude Code `ask` hook to Codex, prefer this order: 1. Use hard `deny` / exit 2 when the command must not run without review. 2. If the risky action can be expressed as command argv prefixes and the user explicitly accepts best-effort native Codex prompts, use Codex rules for the prompt and keep the hook silent for that exact reason code. 3. Do not put the whole hook into shadow mode unless you intentionally want logging only; that allows every risky action the hook would otherwise catch. For bash-guard specifically, Codex live mode defaults to hard `deny` for internal `ask` decisions. Only reason codes listed in `BASH_GUARD_CODEX_DEFER_REASON_CODES` are allowed to pass through to execpolicy, and installers only add that env var when the user passes `--codex-native-prompts`. Each deferred reason code must have a matching `prefix_rule(... decision="prompt")`; otherwise the risky command would be silently allowed. `PermissionRequest` only fires when Codex is already about to ask for approval. It can return `allow`, `deny`, or no decision; it cannot create a prompt for commands that Codex would otherwise run without asking. Codex `PreToolUse` can intercept Bash, `apply_patch` file edits, and MCP tool calls, but it is not a complete enforcement boundary: interception of richer shell paths is incomplete, and WebSearch / non-shell / non-MCP tool calls are out of scope. See [references/codex-hooks.md](references/codex-hooks.md) for full Codex hooks reference, all event input/output schemas, common patterns, and migration from the legacy `AfterAgent` / `AfterToolUse` events. ## OpenCode Hooks (Plugin-based) OpenCode (anomalyco/opencode v1.14.x) does NOT use config-based shell hooks. Hooks are TypeScript/JavaScript **plugins** that subscribe to lifecycle events. The closest analogue to `PreToolUse` is `tool.execute.before` — throwing inside it blocks the tool call. ```typescript // .opencode/plugins/env-protection.ts import type { Plugin } from "@opencode-ai/plugin" export default (async () => ({ tool: { execute: { before: async (input, output) => { if (output.args.filePath?.includes(".env")) { throw new Error("Reading .env is forbidden") } }, }, }, })) satisfies Plugin ``` **Plugin locations**: - Project: `.opencode/plugins/*.ts` - Global: `~/.config/opencode/plugins/*.ts` - npm packages: listed in `opencode.json` under `plugin: []` **Common events**: `tool.execute.before`, `tool.execute.after`, `session.idle`, `session.created`, `file.edited`, `permission.asked`, `command.executed` (~25 total). **Critical caveat (v1.14.x)**: `tool.execute.*` hooks **do NOT** fire for MCP tool calls — use the `permission` block in `opencode.json` to control MCP tool access instead. For "ask before" semantics, prefer `permission` rules over plugin throws — they integrate with the built-in confirm UI: ```json { "permission": { "bash": { "rm -rf *": "ask" } } } ``` See [references/opencode-hooks.md](references/opencode-hooks.md) for the full event catalog, migration patterns from Claude Code hooks, and npm plugin distribution. ## Event Input Schemas See [references/claude-event-schemas.md](references/claude-event-schemas.md) for complete JSON input schemas for each event type (Claude Code). ## Validation Run validation script to check hooks: ```bash python3 "$SKILL_PATH/scripts/validate_hooks.py" <settings-file> ``` Validates: - JSON syntax - Required fields (type, command/prompt) - Valid event names - Matcher patterns (regex validity) - Command syntax basics ## Removing Hooks 1. Read current config 2. Identify hook by event + matcher + command pattern 3. Remove from hooks array 4. If array empty, remove the matcher entry 5. If event empty, remove event key 6. Validate and save ## Exit Codes | Code | Meaning | Use Case | |------|---------|----------| | 0 | Success/Allow | Continue execution | | 2 | Block | Simple blocking (prefer JSON decision control for PreToolUse) | | Other | Error | Log to stderr, shown in verbose mode | ## Security Checklist Before adding hooks, verify: - [ ] No credential logging - [ ] No sensitive data exposure - [ ] Specific matchers (avoid `*` when possible) - [ ] Validated input parsing - [ ] Appropriate timeout for long operations ## Troubleshooting **Hook not triggering**: Check matcher case-sensitivity, ensure event name is exact. **Command failing**: Test command standalone with sample JSON input. **Permission denied**: Ensure script is executable (`chmod +x`). **Timeout**: Increase timeout field or optimize command.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.