HookStack
All guides

How to Secure Claude Code with Hooks

8 min read · Reviewed 2026-06-12

Claude Code is an agent that runs shell commands and edits files in your repository. That is exactly what makes it useful, and exactly what makes it worth constraining. A single misread instruction can turn into an `rm -rf`, a committed API key, or a force-push to `main`. You want the productivity without handing the agent unconditional write access to your machine.

The strongest control you have is the hook system. A `PreToolUse` hook runs before every tool call and can block it deterministically — the model does not get a vote. This guide walks through the concrete guardrails that matter most: blocking dangerous commands, stopping secret leaks, protecting sensitive files, and defending your default branch. Each one is a small, testable script you can install and read in full.

Why do you need security guardrails for an AI coding agent?

The agent decides what to do from natural language. Most of the time it gets it right, but "most of the time" is not a security boundary. When you ask it to clean up a directory, the difference between deleting build artifacts and deleting your home folder is one path the model inferred under uncertainty.

You might reach for CLAUDE.md and write "never run destructive commands". That instruction is probabilistic — it is one more piece of context competing for the model’s attention, and it can be misread, deprioritized, or simply lost in a long session. A hook is different. It is code that runs every time the matching tool fires, before the action happens, and its verdict is final.

The practical takeaway: put intent in CLAUDE.md, but put enforcement in hooks. A PreToolUse guard is deterministic — it runs on every call and the model cannot skip it.

How do you block destructive shell commands?

Shell access is the highest-leverage risk surface, so it gets the first guard. A PreToolUse hook with the matcher Bash inspects every command the agent wants to run and returns { decision: 'block', reason: '…' } to stop it. Filter on tool_name first, then match the dangerous patterns you care about — recursive force-deletes, piping a remote script straight into a shell, disk-wipe utilities.

import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'

const DANGEROUS = [
  /rm\s+-rf?\s+(\/|~|\$HOME)/,
  /:\(\)\s*\{.*\};:/,
  /\b(mkfs|dd)\b/,
  /curl[^|]*\|\s*(sh|bash)/
]

export function run(input) {
  if (input.tool_name !== 'Bash') return null
  const command = input.tool_input?.command ?? ''
  for (const pattern of DANGEROUS) {
    if (pattern.test(command)) {
      return { decision: 'block', reason: 'Blocked a potentially destructive shell command. Run it manually if you are certain.' }
    }
  }
  return null
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const input = JSON.parse(readFileSync(0, 'utf8'))
  const result = run(input)
  if (result) process.stdout.write(JSON.stringify(result))
}

The matcher Bash is the key detail: this hook only ever sees Bash tool calls, so it stays cheap and focused. Returning null lets everything else through unchanged.

How do you stop secrets from leaking?

Secrets leak two ways: the agent echoes a credential into a shell command, or it writes one into a file you commit. A single PreToolUse hook can cover both by registering against Bash for command inspection and Write|Edit for file content. Scan the relevant payload against a small set of high-signal patterns and block on a match.

import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'

const SECRETS = [
  /sk-[a-zA-Z0-9]{20,}/,
  /AKIA[0-9A-Z]{16}/,
  /ghp_[a-zA-Z0-9]{36}/,
  /-----BEGIN (RSA |EC )?PRIVATE KEY-----/,
  /xox[baprs]-[a-zA-Z0-9-]{10,}/
]

export function run(input) {
  const text =
    input.tool_name === 'Bash'
      ? input.tool_input?.command ?? ''
      : input.tool_input?.content ?? input.tool_input?.new_string ?? ''
  if (!text) return null
  for (const pattern of SECRETS) {
    if (pattern.test(text)) {
      return { decision: 'block', reason: 'A value matching a secret pattern was detected. Use an environment variable or a secrets manager instead.' }
    }
  }
  return null
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const input = JSON.parse(readFileSync(0, 'utf8'))
  const result = run(input)
  if (result) process.stdout.write(JSON.stringify(result))
}

Wire it to both events in settings.json so it covers the full surface: the matcher Bash catches echo $TOKEN or export KEY=…, and the matcher Write|Edit catches a key being written into source. Pattern lists are never exhaustive, so treat this as a tripwire that catches the common shapes, not a guarantee.

How do you protect sensitive files like .env?

Some files should never be in the agent’s working set at all. A PreToolUse hook on Read and Edit can block any access to .env, private keys, and credential files, so a secret never even enters the model’s context — which is the cleanest way to prevent it from being echoed or copied later.

import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'

const PROTECTED = [
  /(^|\/)\.env(\.|$)/,
  /\.pem$/,
  /(^|\/)id_(rsa|ed25519)$/,
  /(^|\/)credentials(\.json)?$/
]

export function run(input) {
  if (!['Read', 'Edit'].includes(input.tool_name)) return null
  const path = input.tool_input?.file_path ?? ''
  if (PROTECTED.some((p) => p.test(path))) {
    return { decision: 'block', reason: 'Access to this file is blocked to keep secrets out of the agent context.' }
  }
  return null
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const input = JSON.parse(readFileSync(0, 'utf8'))
  const result = run(input)
  if (result) process.stdout.write(JSON.stringify(result))
}

You can extend the same idea to lockfiles and CI configuration when you want the agent to read source but not silently rewrite the files that govern your builds.

How do you protect the main branch?

Two failure modes hit the default branch: the agent edits files while main is checked out, and the agent pushes directly to main. Both are easy to gate with hooks. A PreToolUse guard on Write|Edit can read the current branch with git rev-parse --abbrev-ref HEAD and block writes until a feature branch exists.

A separate Bash guard inspects the command and blocks any git push whose target is main or master, including --force variants. Always pass a timeout to execSync so a hung git call cannot stall the session.

  • Block writes on main: nudge the agent to create a branch first, so no change lands on the protected branch by accident.
  • Block git push origin main: a push is irreversible in a way a local edit is not, so it deserves its own deterministic gate.
  • Allow everything on feature branches: the guards return null once you are off main, staying out of the way during normal work.

How do you keep secrets off the screen during a demo or screen-share?

Security is not only about what the agent does — it is also about what ends up on your screen while you record a demo or pair over a call. A hook that runs when messages are displayed can redact values matching secret patterns before they render, replacing them with a placeholder like [REDACTED].

This does not stop a leak at the source, so pair it with the file and command guards above. Its job is narrow and worth it: a token that scrolls past during a screen-share is a real disclosure, and redacting the display closes that specific gap.

How do these compose into a security baseline, and how do you install them?

Each hook is small and single-purpose, and together they form layered defense: command blocking, secret scanning, file protection, branch guards, and display redaction. No single one is sufficient, but stacked they cover the realistic ways an agent causes harm. This is defense-in-depth, not a replacement for reviewing what the agent actually does — hooks catch the patterns you anticipated, your attention catches the rest.

HookStack groups these under the security category so you do not have to assemble them by hand. Select the guards you want from the catalogue, then install the whole stack in one step:

npx hookstack-cli@latest install

The command writes the hooks into .claude/hooks/ and wires the matchers into your settings.json. From the next session on, every matching tool call passes through your guards before it runs — deterministically, with no instruction for the model to forget.

Frequently asked questions

Can the model disable or skip a PreToolUse hook?
No. Hooks are configured in `settings.json` and executed by Claude Code itself, not by the model. A `PreToolUse` guard runs before every matching tool call, and its `block` decision is final regardless of what the model intends.
Is a CLAUDE.md instruction enough to keep the agent safe?
No. Instructions in `CLAUDE.md` are probabilistic context the model may misread or deprioritize. Use it to express intent, but enforce hard limits with deterministic `PreToolUse` hooks that run every time.
Do these hooks replace code review?
No. Hooks are defense-in-depth: they block the dangerous patterns you anticipated. You still need to review what the agent changed, because no pattern list covers every way an action can go wrong.
Will secret-detection hooks slow down my session?
No meaningfully. The hooks filter on `tool_name` first and run a handful of regular expressions against a single payload, so the overhead per tool call is negligible compared to the model and tool latency.

Related hooks

Sources

Read next