Dev.to Security 🔐 Cybersecurity 👁 0 📖 5 min read

Five hooks I never wrote ran in every Claude Code session

Hook entries merge across settings levels instead of replacing each other, so a session runs the union of your settings, the repo's, your plugins' and your administrator's. My test repo described four of the nine hooks t

Hook entries merge across settings levels instead of replacing each other, so a session runs the union of your settings, the repo's, your plugins' and your administrator's. My test repo described four of the nine hooks that actually fired.

I wired four hooks into a throwaway git repo to see what they would catch, ran Claude Code against them four times headless, and logged every invocation to a file so I could read what fired instead of trusting the summary at the end. Claude Code 2.1.317, Opus 5.5, about 100 seconds of runtime in total.

The hooks did their job. The log also showed five hooks I had not written, firing before my first prompt in every single run.

Hooks merge, they do not override

This is the sentence in Anthropic's reference that explains it, and it still read the same when I checked today: "Hook entries merge across settings levels rather than replacing each other: user, project, and local settings add their own hooks without removing managed ones."

A hook can be defined in six places — your user settings, a project's .claude/settings.json committed to git, a .claude/settings.local.json that is yours alone, managed policy settings, a plugin, or the frontmatter of a skill or subagent. None of these shadow the others. The session runs the union.

My repo's .claude/settings.json declared four handlers:

{
  "hooks": {
    "UserPromptSubmit": [
      { "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/add-context.sh" } ] }
    ],
    "PreToolUse": [
      { "matcher": "Bash", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm.sh" } ] }
    ],
    "PostToolUse": [
      { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-python.sh" } ] }
    ],
    "Stop": [
      { "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-tests.sh" } ] }
    ]
  }
}

Four entries. Nine hooks ran. The other five were SessionStart handlers belonging to plugins installed in my own user settings, and two of them injected context into the conversation before my prompt arrived. Nothing in the repository mentioned them, because nothing in the repository knows they exist.

To be clear about what this is and is not: those were my own plugins, doing what I installed them to do. Nothing misbehaved. The finding is narrower and more boring than a security story — the repository does not describe the session. Read .claude/settings.json and you have read a quarter of what will run.

Why the composition is the thing to reason about

Two directions, and they are not symmetric.

Your machine into someone else's repo. You clone a project, open it, and your personal hooks run beside its hooks. If yours injects context, that context lands in a session working on code you have not read. If theirs is a PreToolUse guard, it constrains you. Neither side declared the other.

Someone else's repo onto your machine. Hooks are ordinary programs. They run with your user's permissions, in your working directory, with Claude Code's environment, which is precisely what makes them useful and what makes a careless one expensive.

There is a real asymmetry in how that second case is gated, and it is worth knowing before you script anything. An interactive session holds a project's hooks until you accept the workspace trust dialog. A claude -p or SDK session never shows that dialog and treats the folder as trusted. So the mode where nobody is watching the screen is also the mode with no prompt — which is the one you reach for in CI.

I ran all four of my tests headless for exactly that reason, and the project hooks fired immediately with no dialog.

The audit surface, and its one gap

/hooks inside an interactive session is the quickest inventory. Per the reference it opens "a read-only browser for your configured hooks", and the list "labels each hook with where it comes from, such as user settings, project settings, local settings, a plugin, or the current session." That provenance label is the useful column — it is the only place the union is shown as a union.

Read-only is the gap. You cannot disable one entry from there; you go to the file that defined it and delete it. The blanket switch is "disableAllHooks": true, and per the reference it "can't disable managed hooks from outside managed settings" — so an administrator's hooks survive it by design.

In the other direction, allowManagedHooksOnly in managed settings blocks user, project, local and plugin hooks outright. If you run a fleet, that is the control; if you are one developer, it is the thing your laptop might already be under.

What I would actually do

Log every hook invocation. My four scripts each appended a line to hooks.log. That single habit is what surfaced the five I had not written — the event stream alone would have shown them, but the log is what made the count obvious. One line per call, with the event name, costs nothing.

Run /hooks after installing a plugin, not before. The merge happens quietly, and the plugin's hooks run in every project you open afterwards, not just the one you were in.

Read a repository's .claude/ before scripting Claude Code over it. Or pass --settings '{"disableAllHooks": true}' for that run, which is the lighter-weight version of the same caution.

Keep hooks for automation, and permissions for boundaries. The reference is explicit that the if condition on a handler is best-effort, and that you should "use the permission system rather than a hook to enforce a hard allow or deny." My run 1 hit both systems within a few seconds of each other: my hook blocked an rm -rf, and Claude Code's own permission system separately refused a command containing $(date ...) because it could not check a nested command before running it. Different mechanisms, different jobs.

One more behaviour worth planning around, because it cuts the other way. In my third run Claude listed the .claude folder and printed the Stop hook's script before writing any code, then wrote the file the hook would have demanded. A hook committed in a repo doubles as documentation, because the model reads it like any other file — which is useful, and is also another reason the contents of .claude/ deserve a review pass rather than a glance.

The full write-up, with all four runs, the exit-code behaviour, the hook scripts and what each one caught, is in my Claude Code hooks test.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.