Claude Skill

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

LLM Mart · 0 points · 1 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download CodeAlive-AI-ai-driven-development-skills_hooks-management-b1fbfa4.zip · 24 KB
Part of codealive-ai/ai-driven-development — 21 skills

Install

skills CLI npx skills add https://github.com/CodeAlive-AI/ai-driven-development/tree/main/skills/hooks-management
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install codealive-ai-ai-driven-development@llmmart
Git 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.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 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 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:

{
  "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):

{
  "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 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.

#!/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:

  • 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:

[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:

  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 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.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:

{ "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

  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.

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.

No comments yet.

Reviews (0)

No reviews yet.

Related