Claude Code Hooks (2026): How to Block Claude From Reading .env, Plus the 30 Hook Events, JSON Input Shapes, and Exit Codes

The PreToolUse hook that blocks Claude from reading .env files (exit code 2 on a file_path match), plus all 30 Claude Code / Agent SDK hook events with exact stdin JSON input fields, JSON output, exit-code semantics, matchers, and the settings.json hooks block. PreToolUse, PostToolUse, UserPromptSubmit, Stop, SubagentStop reference.

June 9, 2026 ยท 2 min read
Claude Code Hooks (2026): How to Block Claude From Reading .env, Plus the 30 Hook Events, JSON Input Shapes, and Exit Codes

To stop Claude Code from reading sensitive .env files, register a PreToolUse hook matched to the file tools (Edit, Write, and Read) that exits with code 2 when the target path matches .env. Exit code 2 blocks the tool call before it runs and feeds the stderr message back to Claude. The hook reads JSON on stdin and parses tool_input.file_path to decide.

Claude Code hooks and Claude Agent SDK hooks fire user code at fixed points in the agent lifecycle. In Claude Code a hook is a shell command that reads JSON from stdin and signals back through exit codes and stdout JSON; in the Agent SDK the same events are in-process TypeScript or Python callbacks on options.hooks. This is the lifecycle reference: every event, the exact JSON each receives, the JSON it can return, exit-code semantics, matchers, timeouts, and the settings.json block that registers them.

Block Claude From Reading .env Files

To block Claude from reading .env files, register a PreToolUse hook on the file tools Edit|Write (and Read) that parses tool_input.file_path from stdin and exits with code 2 when the path contains .env. Exit code 2 blocks the tool call and sends the stderr message to Claude as feedback. This is the only reliable block: permission rules and .claudeignore can be bypassed by indexing and system reminders, but a PreToolUse hook runs before every matching tool call.

This is the exact script from the official Claude Code hooks guide (code.claude.com/docs/en/hooks-guide), which protects .env, package-lock.json, and .git/. Save it to .claude/hooks/protect-files.sh and chmod +x it:

.claude/hooks/protect-files.sh (verbatim from the official guide)

#!/bin/bash
# protect-files.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")

for pattern in "${PROTECTED_PATTERNS[@]}"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
    exit 2
  fi
done

exit 0

Register it as a PreToolUse hook matched to Edit|Write in .claude/settings.json. The matcher is the tool name, so to also block the Read tool and shell reads, extend the matcher to Read|Edit|Write and add a Bash matcher that parses tool_input.command for cat .env / less .env:

settings.json: register the PreToolUse hook (official structure)

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
          }
        ]
      }
    ]
  }
}

The PreToolUse hook receives this stdin JSON. tool_name is the tool being called and tool_input.file_path is the path the file tools (Read, Edit, Write) target; Bash calls instead carry tool_input.command:

Exact JSON a PreToolUse hook reads on stdin

{
  "session_id": "abc123",
  "transcript_path": "/Users/dev/.claude/projects/.../transcript.jsonl",
  "cwd": "/Users/dev/myproject",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "tool_name": "Read",
  "tool_input": { "file_path": "/Users/dev/myproject/.env" }
}

The deterministic complement is a deny permission rule in settings.json. Permission evaluation runs deny, then ask, then allow, and a deny at any scope cannot be overridden:

settings.json: deny reads with gitignore-style paths

{
  "permissions": {
    "deny": [
      "Read(./.env)",
      "Read(./.env.*)",
      "Read(./secrets/**)",
      "Bash(cat ./.env *)"
    ]
  }
}
Why the hook is the reliable block, not .claudeignore

A .claudeignore file or a permission allow-list can be bypassed: Claude can still surface ignored file contents through indexing, codebase search, and system-reminder injection, and an allow rule never stops a read it did not anticipate. A PreToolUse hook with exit 2 is enforced deterministically on every matching tool call, so pair the hook (covers dynamic logic and Bash reads) with a deny rule (covers the file tools at the permission layer) for full coverage.

A hand-rolled PreToolUse hook is fine for a single repo. To block secret-file reads or label agent traces at scale across many agents, Morph Reflexes are the managed alternative: fine-tuned classifiers and guards that you call from a hook to decide allow or deny. Morph Reflexes are OpenAI-fine-tuning-compatible (/v1/fine_tuning/* to train, /v1/reflex/predict to run), so a PreToolUse hook can POST the candidate file_path to a Reflex and exit 2 when it returns a block label.

The Hook Lifecycle: All 30 Events

Claude Code and the Agent SDK share 30 hook events across session, prompt, tool, subagent, task, file, and notification phases. The five most queried (PreToolUse, PostToolUse, UserPromptSubmit, Stop, SubagentStop) are highlighted. "Can block" means exit 2 or decision: "block" stops the action.

Hook events (full list)
EventWhen it firesCan block?
SessionStartSession begins/resumes (startup, resume, clear, compact)No
SetupFirst-run setup of a sessionNo
SessionEndSession terminates (clear, resume, logout, etc.)No
UserPromptSubmitBefore Claude processes a submitted promptYes
UserPromptExpansionPrompt expansion phaseNo
PreToolUseBefore a tool call executesYes
PostToolUseAfter a tool call succeedsNo
PostToolUseFailureAfter a tool call fails (adds error_message, error_type)No
PostToolBatchAfter a batch of tools (exit 2 stops the loop)Yes
PermissionRequestWhen a permission dialog would appearYes
PermissionDeniedAfter a permission is deniedNo
SubagentStartA subagent is spawned (adds agent_prompt)No
SubagentStopA subagent finishesYes
StopClaude finishes respondingYes
StopFailureTurn ends on an API error (rate_limit, overloaded, billing_error)No
TeammateIdleAn agent-team teammate goes idleNo
TaskCreatedA task is createdNo
TaskCompletedA task is marked completedNo
FileChangedA watched file changesNo
CwdChangedThe working directory changesNo
ConfigChangeConfiguration changesNo
InstructionsLoadedCLAUDE.md / instructions are loadedNo
WorktreeCreateA git worktree is createdNo
WorktreeRemoveA git worktree is removedNo
NotificationClaude Code sends a notificationNo
MessageDisplayA message is displayed (10s default timeout)No
PreCompactBefore context compaction (manual, auto)Yes
PostCompactAfter context compactionNo
ElicitationAn elicitation dialog opensNo
ElicitationResultAn elicitation completesNo

Common JSON Input (Every Event)

Every hook event receives these fields on stdin. Per-turn and per-tool events add permission_mode; tool and subagent events add effort; events running inside a subagent add agent_id and agent_type.

Common stdin fields on every hook event

{
  "session_id": "abc123",
  "transcript_path": "/Users/dev/.claude/projects/.../transcript.jsonl",
  "cwd": "/Users/dev/myproject",
  "hook_event_name": "PreToolUse",
  "permission_mode": "default",
  "effort": { "level": "high" },
  "agent_id": "agent_01...",
  "agent_type": "general-purpose"
}

PreToolUse: Input + Output Shapes

Fires before a tool call. Matcher is the tool name (exact, |-list, or JS regex such as mcp__memory__.*). Exit 2 blocks the call. Default timeout 600s. PreToolUse runs before the permission prompt and can deny, force a prompt, or allow, but a hook decision never bypasses a deny or ask permission rule.

PreToolUse stdin input

{
  "session_id": "abc123",
  "cwd": "/Users/dev/myproject",
  "hook_event_name": "PreToolUse",
  "permission_mode": "default",
  "effort": { "level": "high" },
  "tool_name": "Bash",
  "tool_input": { "command": "npm test" }
}

PreToolUse JSON output: deny, with optional updatedInput

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Use rg instead of grep for performance",
    "updatedInput": { "command": "rg --files-with-matches TODO" },
    "additionalContext": "Repo policy: prefer ripgrep."
  }
}

permissionDecision accepts allow, deny, ask, or defer. updatedInput rewrites the tool arguments before execution; additionalContext injects a note for Claude. PreToolUse also supports an if field for fine-grained filtering.

PostToolUse: Input + Output Shapes

Fires after a tool call succeeds (the tool already ran, so this cannot undo it). Input adds tool_name, tool_input, and tool_output (the tool result).

PostToolUse stdin input

{
  "session_id": "abc123",
  "cwd": "/Users/dev/myproject",
  "hook_event_name": "PostToolUse",
  "tool_name": "Edit",
  "tool_input": { "file_path": "src/index.ts", "old_string": "a", "new_string": "b" },
  "tool_output": { "success": true }
}

PostToolUse JSON output: feed a reason back, or rewrite the tool result

{
  "decision": "block",
  "reason": "Lint failed on the edited file; fix before continuing.",
  "hookSpecificOutput": {
    "hookEventName": "PostToolUse",
    "updatedToolOutput": "formatted result returned to Claude",
    "additionalContext": "Auto-formatted with prettier."
  }
}

Top-level {"decision": "block", "reason": ...} feeds the reason to Claude even though the tool already ran. hookSpecificOutput.updatedToolOutput rewrites the tool result. Exit 2 shows stderr to Claude. Companion events: PostToolUseFailure (adds error_message, error_type) and PostToolBatch (input has a tools array; exit 2 stops the agentic loop before the next model call).

UserPromptSubmit: Input + Output Shapes

Fires before Claude processes a user prompt. No matcher support. Input adds prompt and permission_mode. Default timeout is 30s, shorter than the 600s default on most events. Exit 2 blocks prompt processing.

UserPromptSubmit stdin input

{
  "session_id": "abc123",
  "cwd": "/Users/dev/myproject",
  "hook_event_name": "UserPromptSubmit",
  "permission_mode": "default",
  "prompt": "refactor the auth module"
}

UserPromptSubmit JSON output: block, or inject context

{
  "decision": "block",
  "reason": "Prompt references a frozen module; open a ticket first.",
  "hookSpecificOutput": {
    "hookEventName": "UserPromptSubmit",
    "additionalContext": "Current sprint: auth refactor. Use Bun, not npm."
  }
}

Stop: Input + Output Shapes

Fires when Claude finishes responding. No matcher. Input is the common fields plus permission_mode. {"decision": "block", "reason"} or exit 2 prevents stopping and forces Claude to continue. StopFailure is a separate event for API-error turn endings, with matchers like rate_limit, overloaded, and billing_error.

Stop stdin input

{
  "session_id": "abc123",
  "transcript_path": "/Users/dev/.claude/projects/.../transcript.jsonl",
  "cwd": "/Users/dev/myproject",
  "hook_event_name": "Stop",
  "permission_mode": "default"
}

Stop JSON output: force Claude to keep working

{
  "decision": "block",
  "reason": "Tests are still failing. Fix them before finishing.",
  "hookSpecificOutput": {
    "hookEventName": "Stop",
    "additionalContext": "Run: bun run test"
  }
}
Avoid the Stop-hook loop

A Stop hook that always returns decision: block never lets Claude stop. Gate the block on a real condition (a failing check), and return exit 0 once the condition clears, so the agent can finish.

SubagentStop: Input + Output Shapes

Fires when a subagent finishes. Matcher is the agent type (general-purpose, Explore, Plan, or a custom name). Input adds agent_id, agent_type, and effort. Exit 2 or decision: "block" prevents the subagent from stopping. The paired SubagentStart fires at spawn (input adds agent_prompt), supports additionalContext, and cannot block.

SubagentStop stdin input

{
  "session_id": "abc123",
  "cwd": "/Users/dev/myproject",
  "hook_event_name": "SubagentStop",
  "agent_id": "agent_01H...",
  "agent_type": "Explore",
  "effort": { "level": "medium" }
}

SubagentStop settings.json: only gate the Explore subagent

{
  "hooks": {
    "SubagentStop": [
      {
        "matcher": "Explore",
        "hooks": [
          { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-explore.sh" }
        ]
      }
    ]
  }
}

Exit Codes and Universal JSON Output

Exit 0: Success

stdout is parsed for JSON output. On UserPromptSubmit and SessionStart, stdout can also be added as context.

Exit 2: Blocking error

stderr is fed back to Claude and the action is blocked on blocking-capable events (PreToolUse, UserPromptSubmit, Stop, SubagentStop, PreCompact).

Other codes: Non-blocking

stderr is shown but execution continues. The action is not blocked.

Beyond per-event fields, every hook can return this universal envelope. continue: false stops processing with stopReason shown to the user.

Universal JSON output (all events)

{
  "continue": true,
  "stopReason": "",
  "suppressOutput": false,
  "systemMessage": "warning shown to the user",
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "additionalContext": "..."
  }
}

Matchers and Timeouts

Matcher "*", "", or omitted matches all. Letters, digits, underscore and | only is an exact name or |-separated list (Edit|Write). Anything else is a JavaScript regex (^Notebook, mcp__memory__.*). MCP tools are named mcp__<server>__<tool>.

What each event matches on
EventMatches onExample matchers
PreToolUse / PostToolUseTool nameBash, Edit|Write, mcp__memory__.*
SessionStartHow the session startedstartup, resume, clear, compact
SessionEndWhy the session endedclear, resume, logout, other
NotificationNotification typepermission_prompt, idle_prompt
SubagentStart / SubagentStopAgent typegeneral-purpose, Explore, Plan
PreCompact / PostCompactCompaction triggermanual, auto
StopFailureAPI error classrate_limit, overloaded, billing_error
Default hook timeouts (per-hook timeout overrides)
Handler / eventDefault timeout
command / http / mcp_tool600s
UserPromptSubmit30s
MessageDisplay10s
prompt handler30s
agent handler60s

Path placeholders inside commands: ${CLAUDE_PROJECT_DIR}, ${CLAUDE_PLUGIN_ROOT}, ${CLAUDE_PLUGIN_DATA}.

settings.json Hooks Schema

Hooks nest three levels: event, matcher group, handler. Handler type is one of command, http (url + headers + allowedEnvVars), mcp_tool (server/tool/input), prompt (single-shot model judgment, 30s), or agent (subagent, 60s). Set disableAllHooks: true to kill all hooks; permissions, hooks, and apiKeyHelper reload without restarting Claude Code.

settings.json hooks block

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/validate.sh",
            "timeout": 30
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null" }
        ]
      }
    ]
  }
}

Agent SDK Hooks (TypeScript + Python)

In the Claude Agent SDK, hooks are in-process callbacks, not shell commands. Same event names, registered on options.hooks. Available events include PreToolUse, PostToolUse, Stop, SessionStart, SessionEnd, and UserPromptSubmit. Install: npm install @anthropic-ai/claude-agent-sdk (TS) or pip install claude-agent-sdk (Python 3.10+).

TypeScript: register a PostToolUse callback

import { query } from "@anthropic-ai/claude-agent-sdk";

const options = {
  allowedTools: ["Read", "Edit", "Bash"],
  hooks: {
    PostToolUse: [
      {
        matcher: "Edit|Write",
        hooks: [
          async (input) => {
            // input mirrors the stdin JSON: tool_name, tool_input, tool_output
            return { hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: "formatted" } };
          },
        ],
      },
    ],
  },
};

for await (const message of query({ prompt: "refactor auth", options })) {
  // ...
}

Python: register a PostToolUse callback

from claude_agent_sdk import query, ClaudeAgentOptions, HookMatcher

async def on_post_tool(input, tool_use_id, context):
    # input mirrors the stdin JSON: tool_name, tool_input, tool_output
    return {}

options = ClaudeAgentOptions(
    allowed_tools=["Read", "Edit", "Bash"],
    hooks={"PostToolUse": [HookMatcher(matcher="Edit|Write", hooks=[on_post_tool])]},
)

async for message in query(prompt="refactor auth", options=options):
    ...

Hook Storage Locations

Hooks load from several scopes. Higher-precedence scopes win, and a managed policy with allowManagedHooksOnly: true blocks all user and project hooks.

Where hooks load from
LocationScopeShareable?
~/.claude/settings.jsonAll your projectsNo (local to machine)
.claude/settings.jsonSingle projectYes (commit to git)
.claude/settings.local.jsonSingle projectNo (gitignored)
Plugin hooks/hooks.jsonWhen plugin enabledYes (bundled with plugin)
Skill / agent frontmatterWhile component activeYes (in file)
Managed policy settingsOrganization-wideYes (admin-controlled)
Run DeepSeek and codegen models behind your hooks

Hooks gate any tool an agent calls, including model calls routed through an LLM gateway. If you point an agent at open-source models, output fidelity matters. Morph Open Source Models serve DeepSeek at 16-bit (bf16) activations with no fp8 or int8 quantization, so responses match the reference weights; most serverless providers quantize activations to fp8 to cut cost and degrade quality. For coding agents, Morph adds codegen-tuned speculative decoding plus custom low-level kernels. morph-dsv4flash (DeepSeek V4 Flash) is $0.139 per 1M input tokens and $0.278 per 1M output; see pricing.

Frequently Asked Questions

What hook events does the Claude Agent SDK / Claude Code support?

The full event list: SessionStart, Setup, SessionEnd, UserPromptSubmit, UserPromptExpansion, Stop, StopFailure, PreToolUse, PostToolUse, PostToolUseFailure, PostToolBatch, PermissionRequest, PermissionDenied, SubagentStart, SubagentStop, TeammateIdle, TaskCreated, TaskCompleted, FileChanged, CwdChanged, ConfigChange, InstructionsLoaded, WorktreeCreate, WorktreeRemove, Notification, MessageDisplay, PreCompact, PostCompact, Elicitation, ElicitationResult. The Agent SDK exposes the same lifecycle as in-process callbacks; the most queried are PreToolUse, PostToolUse, UserPromptSubmit, Stop, and SubagentStop.

What JSON input do PreToolUse, PostToolUse, UserPromptSubmit, Stop, and SubagentStop hooks receive?

Common fields on every event: session_id, transcript_path, cwd, hook_event_name (plus permission_mode on per-turn/tool events, effort on tool/subagent events, agent_id/agent_type inside a subagent). PreToolUse adds tool_name, tool_input, effort. PostToolUse adds tool_name, tool_input, tool_output. UserPromptSubmit adds prompt and permission_mode. Stop adds permission_mode (no matcher). SubagentStop adds agent_id, agent_type, effort and matches on agent type.

Which hook prevents Claude from reading sensitive .env files, and what tool names does it target?

Use a PreToolUse hook (it runs before the call and exit 2 blocks it) matched on the file tools: Read, Edit, Write, and Bash (for cat/less reads). Parse tool_input.file_path (and tool_input.command for Bash) and exit 2 on a .env match. The deterministic complement is a deny permission rule: Read(./.env), Read(./.env.*), Read(./secrets/**). A deny wins over any allow at any scope.

How does Claude Code handle .env files?

Claude Code treats .env files as normal readable files unless you block them. There is no built-in secret redaction: the Read tool returns .env contents and the Bash tool can cat or grep them. A .claudeignore entry or permission allow-list does not fully prevent access, because ignored files can still surface through indexing, codebase search, and system-reminder injection. The reliable block is a PreToolUse hook matched to Edit|Write|Read that exits with code 2 when tool_input.file_path matches .env, paired with a deny rule like Read(./.env).

What file types can Claude Code read?

The Read tool reads any UTF-8 text file (source code, .env, JSON, YAML, Markdown, config), plus images (PNG, JPG) shown visually, PDFs by page range, and Jupyter notebooks (.ipynb) as cells with outputs. The Bash tool can read anything the shell reaches with cat, less, head, tail, or grep. There is no allow-list of file types by default, so to keep Claude out of secrets you add a PreToolUse hook or a deny permission rule that matches the sensitive paths.

What JSON output can a PreToolUse hook return?

hookSpecificOutput.permissionDecision set to allow, deny, ask, or defer, with permissionDecisionReason, plus optional updatedInput (rewrite the tool arguments) and additionalContext. Exit 2 also blocks the call. Default PreToolUse timeout is 600s. Hook decisions never bypass a deny or ask permission rule.

How are SDK hooks written in TypeScript and Python?

Agent SDK hooks are in-process callbacks. TypeScript: options.hooks = { PostToolUse: [{ matcher: "Edit|Write", hooks: [callbackFn] }] }. Python: hooks={ "PostToolUse": [HookMatcher(matcher="Edit|Write", hooks=[fn])] }. Available events include PreToolUse, PostToolUse, Stop, SessionStart, SessionEnd, and UserPromptSubmit.

What are the exit codes for Claude Code hooks?

Exit 0 is success and stdout is parsed for JSON output. Exit 2 is a blocking error: stderr is fed back to Claude and the action is blocked on blocking-capable events. Any other exit code is a non-blocking error: stderr is shown but execution continues.

What is the settings.json hooks block structure?

{"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "..."}]}]}}. Handler types are command, http, mcp_tool, prompt (30s timeout), and agent (60s timeout). Set disableAllHooks: true to kill all hooks. Permissions, hooks, and apiKeyHelper reload without restarting Claude Code.

How do hook matchers work?

Matcher "*", "" or omitted matches all. Letters, digits, underscore and | only is an exact name or |-list (Edit|Write). Anything else is a JavaScript regex (^Notebook, mcp__memory__.*). MCP tools are named mcp__<server>__<tool>. PreToolUse/PostToolUse match the tool name; SessionStart matches startup/resume/clear/compact; SubagentStop matches the agent type.

How do I prevent a Stop hook from running forever?

A Stop hook exits 2 or returns decision: "block" to force Claude to keep going. Gate the block on a real condition (a failing check) and return exit 0 once it clears, so the agent can finish. The Stop event receives session_id, transcript_path, cwd, permission_mode, and hook_event_name. StopFailure is a separate event for API-error turn endings with matchers like rate_limit, overloaded, and billing_error.

Give Your Agent a Search Subagent

WarpGrep is a search subagent for Claude Code and the Agent SDK. $0 for 100k requests, $1 per 1M on Pro. A PostToolUse hook can log every search; a PreToolUse hook can gate which queries run.