.env
.env is protected, and works around it
That is the entire concept. Everything below is detail on how each box works.
01 · The moment you need them
You ask Claude to fix a login bug. It reads the code, forms a theory, and decides the fastest fix is to change a value in .env. That is a reasonable engineering instinct. It is also the one file you never want an agent to touch, because it holds production credentials and it is not in version control, so a bad edit is not recoverable with git checkout.
You could write “never edit .env” in CLAUDE.md. That usually works. But it is an instruction competing with every other instruction in context, and it is advisory: nothing enforces it.
Instructions tell Claude what it should do. Hooks let your system react to what Claude actually does.
One is a request. The other is your code, running on your machine, at a fixed point in the agent's loop — with the power to say no.
02 · The analogy that makes it click
Think of Claude as a competent new employee in your office building, and hooks as the building's security system.
The employee. Capable, well-intentioned, allowed to move around and get work done.
the agentThe doors. Reading a file, writing a file, running a command — each one is a door into a room.
Read · Edit · Write · BashSecurity and sensors. They sit at the doors, not inside the employee's head.
your scriptsFrom there the three hooks you will use most explain themselves:
- PreToolUse is the badge reader outside the room. It checks before you go in, and it can refuse.
- PostToolUse is the quality inspector who walks in afterwards. It cannot un-open the door, but it can tidy up, log what happened, or flag a problem.
- Stop is the person at the exit asking “did you actually run the tests?” before you go home — and who can send you back in.
03 · Where hooks sit in a run
A Claude Code session is a loop, and hook events are named points along it. Here is the path a single tool call takes:
Scroll the row sideways if it is cut off. The important shape: PreToolUse is the only point that can stop an action before it happens. Everything after it is reacting to something that already ran.
Show the full lifecycle (33 events)
You do not need these to start, and memorising them is not the point. They exist so that almost anything the agent does can be observed or gated — subagents, tasks, compaction, model switches, config changes and worktrees all have their own events.
SessionStartSetupUserPromptSubmitUserPromptExpansionPreToolUsePermissionRequestPermissionDeniedPostToolUsePostToolUseFailurePostToolBatchNotificationMessageDisplaySubagentStartSubagentStopTaskCreatedTaskCompletedStopStopFailureTeammateIdleInstructionsLoadedConfigChangeCwdChangedDirectoryAddedFileChangedWorktreeCreateWorktreeRemovePreCompactPostCompactPreModelSwitchPostModelSwitchElicitationElicitationResultSessionEnd
04 · The anatomy of a hook
Every hook is the same three decisions, no matter how complex it gets:
HOOK = EVENT + MATCHER + ACTION
When should this fire · which calls do I care about · what do I run.
The event
The point in the loop where your code runs. PreToolUse means “before the tool executes”, which is the only place a decision can still change the outcome.
The matcher
A regular expression against the tool name, so the hook only wakes for calls it cares about. Edit|Write means file-writing tools and nothing else — a Read or a Bash call never reaches this script.
The handler
A command you own. Claude Code runs it and writes a JSON payload to its stdin, describing the call: tool_name, tool_input, cwd, session_id and more.
The result
Your script answers on stdout. Returning permissionDecision: "deny" blocks the call and hands your reason back to Claude as feedback. Printing nothing means “no opinion” and the normal permission flow continues.
Written out, that is this — and this shape is every hook you will ever write:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-files.mjs"
}
]
}
]
}
}
05 · Your first hook: format on every edit
The smallest useful hook does not block anything. It runs Prettier over whatever file Claude just wrote, so formatting stops being something either of you thinks about.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs -r npx prettier --write"
}
]
}
]
}
}
The command reads the same JSON payload on stdin, pulls one field out of it with jq, and passes it to Prettier.
Why PostToolUse and not PreToolUse?
Because formatting is something you do to a file that exists. Before the tool runs, the change has not happened yet — there is nothing on disk to format. Pick the event by asking whether you need to influence the action or react to it.
06 · The one that says no
Now the .env problem from the top. This is a real hook, not pseudocode: it reads the payload on stdin, compares the target path against a protected list, and answers.
#!/usr/bin/env node
import { readFileSync } from "node:fs";
import path from "node:path";
const PROTECTED = [
/(^|\/)\.env(\..+)?$/, // .env, .env.local, .env.production
/(^|\/)\.git\//, // anything inside .git
/(^|\/)\.ssh\//, // keys
/(^|\/)credentials\.json$/,
];
// Claude Code writes the call description to stdin as JSON.
const payload = JSON.parse(readFileSync(0, "utf8"));
const target = payload.tool_input?.file_path ?? "";
const rel = path.relative(payload.cwd ?? process.cwd(), target) || target;
if (PROTECTED.some((re) => re.test(rel))) {
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason:
`${rel} is protected. Change it by hand, or ask a human.`,
},
}));
}
// Print nothing = no opinion. The normal permission flow continues.
process.exit(0);
Two things worth noticing. It says why, not just no — that reason goes back to Claude, which is the difference between it getting stuck and it finding another route. And silence is a valid answer: a hook that has no opinion should print nothing rather than approving everything.
Run something through it
Pick what Claude tries to do and watch the hook decide. The rules below are the ones in the script above.
Claude wants to:
07 · Hooks are not only about blocking
Blocking is the loudest use, not the most common one. Six things people actually wire up:
Write every tool call to a log so you can see afterwards what the agent actually did.
PostToolUse → append to a fileRun the formatter, regenerate types, restart a dev server after a file changes.
PostToolUse → prettierRefuse edits to credentials, migrations, or anything with no undo.
PreToolUse → denyInject context at the start of a session: the current branch, the ticket, today's deploy state.
SessionStart → stdout becomes contextPing you when a long run finishes or the agent is waiting on a decision.
Notification · StopRefuse to let the session end while the tests are red, and say so.
Stop → continue: trueThe Enrich row is worth a second look: on a few events — SessionStart, UserPromptSubmit among them — whatever your script prints on stdout is added to the context Claude can see. A three-line shell script can tell it which branch you are on.
08 · Rules versus judgement
A hook does not have to be a script. Alongside command, a hook can be a prompt or an agent — Claude evaluating the situation instead of your regex.
Deterministic
type: "command" — your code, same answer every time.
- Fast and free
- Testable like any other script
- Cannot be talked out of a decision
- Only as good as the pattern you wrote
Judgement
type: "prompt" or "agent" — a model weighs it up.
- Handles cases you did not enumerate
- Reads intent, not just strings
- Costs tokens and time on every call
- Not perfectly repeatable; agent hooks are experimental
Rule of thumb: if you can express the rule with an if, write the if. Reach for a prompt or agent hook only when the decision genuinely needs understanding — “is this commit message describing what actually changed?” is judgement; “is this file .env?” is not.
09 · What it looks like assembled
Put four small hooks together and the loop stops being a chat and starts being a workflow with checks in it:
None of those four is clever on its own. Together they mean the agent cannot touch your secrets, cannot leave the codebase badly formatted, cannot quietly finish on a broken build, and cannot claim it did not know which branch it was on. That is the whole pitch: same model, bounded system.
10 · A hook is code. Treat it like code.
A command hook runs as a shell command with your permissions. Not Claude's permissions, not a sandbox — yours. It can modify or delete anything your user account can reach, and it runs automatically, without asking, on every matching call.
- Validate the input. The payload describes something the agent proposed; treat the strings in it as untrusted.
- Quote your variables. An unquoted
$VARin a shell hook is a command-injection hole, and the value came from a model. - Guard against path traversal. Resolve to an absolute path before comparing, so
../../.envdoes not sail past a check that only looked at the basename. - Use absolute paths for the script itself, so behaviour does not depend on the working directory.
- Do not read secrets in a hook just because it is convenient.
The failure mode to avoid is copying a .claude/settings.json from a random repository and letting it run. You would not curl | bash a stranger's script. A hook config is the same thing with extra steps: read it before it runs.
Hook event reference
The ones worth knowing by name, and what each is for.
| Event | Fires | Use it to |
|---|---|---|
| SessionStart | session opens | inject context — stdout is added to what Claude sees |
| UserPromptSubmit | you send a prompt | add context, or reject the prompt |
| PreToolUse | before a tool runs | allow, deny, or rewrite the tool input |
| PermissionRequest | a call needs approval | auto-approve known-safe calls |
| PostToolUse | after a tool succeeds | format, log, regenerate |
| PostToolUseFailure | after a tool errors | react to failures, add a hint |
| Stop | Claude is about to finish | send it back if the work is not verified |
| SubagentStop | a subagent finishes | check a delegated result |
| Notification | Claude needs you | send yourself a desktop or phone ping |
| PreCompact | before context compaction | persist anything you do not want summarised away |
| SessionEnd | session closes | clean up, write a summary |
Behaviour and event names verified against the official Claude Code hooks reference, September 2026. Hooks are evolving quickly — check the official reference before relying on a detail here.