PTENES
INEMA.CLUBPROClaude Code Hooks

Claude Code Hooks / Track 02

Track 02 · Guardrails

Guardrails:
stop the dangerous

Turn "please don't do this" into a real safeguard: block dangerous commands and files before they can cause harm, decide allow/ask/deny, and protect secrets — without relying on the model to remember.

4lessons
~36minutes
intermediatelevel

Trail 02 · Lesson 1 · Intermediate

The idea of
a blocks

A lock is just a PreToolUse hook that decides, before the tool runs, whether it can proceed. The signal is the exit code — and that's all you need to get started.

↓ scroll to study

01 What a block is

A lock is a PreToolUse: it runs before of the tool and decides whether it can proceed. The signal is the exit code:

  • exit 0 — that’s fine, you can run it.
  • exit 2 — block. Claude doesn't execute it, and what you printed to stderr becomes the reason.

Notice the difference: the guard doesn’t “notify you afterward” — it prevents it first. It's the difference between a fire extinguisher and a fire report. In the next lessons, you'll teach this guardrail to read the command, cover the cases that really hurt, and say allow/ask/deny — but the foundation is just this: exit 0 continues, exit 2 blocks.

Test yourself

Which exit code BLOCKS the action in PreToolUse?

02 The reason goes back to Claude

When the guard blocks, Claude isn’t left in the dark: everything your script prints to stderr before the exit 2 becomes the reason that it reads. It’s your only chance to explain why—write for it, not for yourself:

# bad: doesn't say anything useful
exit 2

# good: Claude knows exactly what to avoid next time
echo "trava: rm -rf apaga tudo sem confirmação — use um caminho específico" >&2
exit 2

Think of the message as a short instruction for Claude to try again the right way, not as a generic error log. "blocked" says little; "blocked: use rm arquivo.txt, no rm -rf pasta/" teaches.

Test yourself

What channel does the text that becomes the "reason" shown to Claude when the block triggers come through?

03 A minimal example: block rm -rf

Put the two pieces together — read the command (more on that in the next lesson) and decide with an exit code — and you have a real safeguard:

# .claude/settings.json → PreToolUse, matcher "Bash"
jq -r '.tool_input.command' | grep -qE 'rm -rf' && {
  echo "trava: rm -rf bloqueado" >&2
  exit 2
}
exit 0

Read from the inside out: get the command from stdin, looks for the pattern rm -rf; if it finds one, print the reason and block; if not, the exit 0 at the end, let it continue. Short, and it already solves the most classic mishap.

04 exit 0 allows

Every command that no matches the dangerous pattern must continue normally—and what ensures that is a exit 0 explicitly at the end of the script. Without it, the exit code of the last command run (the grep, for example) can leak and confuse Claude Code.

  • Finish always with exit 0 after the check block — even if it seems obvious.
  • A lock that reacts to just one pattern and lets everything else through is the right behavior: you want to block the dangerous, not the rest of the work.

With that, the cycle is complete: read the command → compare it with the standard → exit 2 (blocks, with a reason) or exit 0 (continues). It’s the smallest safeguard there is, and it’s already real — Claude can’t simply “decide to ignore” it.

Going deeper

In addition to the exit code, a PreToolUse hook can return a richer decision in JSON via stdout (with allow/ask/deny and a reason — without deleting anything. ~5 min, safe: it only asks for confirmation.

Practice now 0/4 done

Your first safeguard: block rm -rf

Make a hook actually block a rm -rf — testing in a disposable location, nowhere near anything important. ~5 min.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "jq -r '.tool_input.command' | grep -qE 'rm -rf' && { echo 'trava: rm -rf bloqueado' >&2; exit 2; }; exit 0" }
        ]
      }
    ]
  }
}

✓ You have a real guardrail: rm -rf blocked before running, with everything else continuing as normal. In the next lesson, it learns to read any command, not just this one.

Trail 02 · Lesson 2 · Intermediate

Read what
goes run

Before deciding, the hook needs to see the actual command—it arrives ready in the stdin JSON; it’s up to you to extract it and compare it with what’s dangerous.

↓ scroll to study

01 The command arrives in tool_input.command

Before running, Claude gives your hook a JSON via standard input (stdin) with the context at the time. Some fields are always present (session_id, cwd, hook_event_name); in PreToolUse, also come tool_name e o tool_input:

{
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": { "command": "rm -rf build/" },
  "cwd": "/home/voce/projeto"
}

Notice: since the tool hasn’t run yet, there is no tool_response at that moment — only the intent. That's exactly the intent your guardrail examines before letting it become an action (or not).

Test yourself

Where does the command that Bash is about to run arrive?

02 Extract with jq -r

To extract plain text from the JSON, use jq. The difference between using -r or isn’t what separates one value usable one full of quotation marks:

# without -r: returns the JSON-encoded value, with quotes
$ jq '.tool_input.command'
"rm -rf build/"

# with -r (raw): returns the raw text, ready to compare
$ jq -r '.tool_input.command'
rm -rf build/

Without -r, the quotes stay attached to the value and get in the way of any text comparison. Get into the habit: in a shell hook, jq -r is almost always what you want.

Test yourself

What’s the difference between jq '.x' e jq -r '.x'?

03 Match dangerous patterns (grep/case)

With the text in hand, compare it with several patterns at once is routine. Two equivalent ways — choose the one that reads better in your script:

# with grep -E (alternation "|")
cmd=$(jq -r '.tool_input.command')
echo "$cmd" | grep -qE 'rm -rf|git push --force|:\(\)\{ :' && exit 2

# with case (more readable as the list grows)
case "$cmd" in
  *"rm -rf"*|*"push --force"*) echo "trava: padrão perigoso" >&2; exit 2 ;;
esac
exit 0

grep -E is shorter for a few patterns; case becomes more readable as the list of patterns grows and each one deserves a comment. Both do the same thing: match the command with a list of what's dangerous.

04 False positives and quotes: watch out for escaping

Reading the command correctly has two common pitfalls:

  • False positive — a grep too broad can catch harmless things (e.g., rm -rf inside a comment or a string). Prefer specific patterns over a standalone word.
  • Never re-interpolate the command in a new shell — get $cmd and pass it back into a eval or somewhere else bash -c "$cmd" opens the door to command injection. Your guard should only read and compare the text, never run it again.

Always use "$cmd" in double quotes when manipulating the variable (to prevent spaces from breaking the comparison), and treat the value as data, not as code to run.

Test yourself

Why shouldn't you re-interpolate $cmd in a new shell (e.g., eval "$cmd") inside the hook itself?

Practice now 0/4 done

Block a git push --force

A hook that reads the actual command and blocks a forced push — the kind of accident that rewrites other people's history. ~5 min.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "jq -r '.tool_input.command' | grep -qE 'push.*(--force|-f)' && { echo 'trava: push --force bloqueado' >&2; exit 2; }; exit 0" }
        ]
      }
    ]
  }
}

✓ The guardrail now reads the command before deciding — it's no longer an isolated fixed pattern; it's a rule that examines the actual text. (Notice that the pattern -f alone is broad — it's the "false positive" from step 04; adjust it if your project uses -f for something else.)

Trail 02 · Lesson 3 · Intermediate

Cases that
apply to worth it

You don't need to block everything — just the costly mistakes. Four cases where the lock pays for itself the first time it prevents damage.

↓ scroll to study

01 rm -rf and paths outside the project

The real danger isn’t the isolated phrase "rm -rf" — it’s the target. One rm -rf inside the folder build/ of the project is hard to recover; a rm -rf /, rm -rf ~ or rm -rf .. is catastrophic. Block broad targets, not syntax:

jq -r '.tool_input.command' | grep -qE 'rm -rf (/|~|\.\.)($| )' && exit 2

An extra check is worth the effort: compare the path against $CLAUDE_PROJECT_DIR (the project root) ensures the command’s target stays within the folder you’re actually willing to risk.

Test yourself

Why block rm -rf / or rm -rf ~ is more urgent than blocking rm -rf build/?

02 push/force on the main branch

git push --force rewrites the remote history—if it’s on the main or master, the damage affects the whole team. Block the specific combination (force + protected branch), not just force on its own:

cmd=$(jq -r '.tool_input.command')
echo "$cmd" | grep -qE -- '--force' && echo "$cmd" | grep -qE '\b(main|master)\b' &&
  { echo "trava: push --force direto na main/master" >&2; exit 2; }
exit 0

Note the two combined conditions: it needs to be strong e point to the main branch. That lets a --force harmless on your own feature branch and blocks only what threatens the team.

03 Touching .env and secrets

Editing files is another event: here the hook looks at tool_input.file_path, not for command. Block secret edits before they happen:

# PreToolUse, matcher "Edit|Write"
file=$(jq -r '.tool_input.file_path')
case "$file" in
  *.env|*.pem|*id_rsa*|*credentials*)
    echo "trava: arquivo protegido ($file)" >&2; exit 2 ;;
esac
exit 0

This blocks creation and editing — including the "first time" someone might try to paste a key into a .env unintentionally versioned.

Test yourself

To block editing a file (rather than a terminal command), which field of the tool_input do you read?

04 Editing protected files in general

The same technique from the previous step generalizes: any file you don’t want changed without review becomes an entry in the list— package-lock.json, .github/workflows/*, the .claude/settings.json. The pattern is always:

  • Event: PreToolUse.
  • Matcher: Edit|Write (matches editing or creating a file).
  • Check: compare tool_input.file_path with your protected list.

There’s no perfect list to start with. Begin with secrets (step 03), then add whatever catches you out — every surprise becomes a new line in the safeguard.

Practice now 0/4 done

Protect the .env

A lock that blocks any file editing or creation .env in the project. ~5 min, and testable with no risk at all.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "jq -r '.tool_input.file_path' | grep -qE '\\.env$' && { echo 'trava: .env é protegido' >&2; exit 2; }; exit 0" }
        ]
      }
    ]
  }
}

✓ Now you block file, not just for commands—the same pattern (matcher Edit|Write + file_path) protects any sensitive file you list.

Trail 02 · Lesson 4 · Advanced

Allow, ask,
deny — and hygiene

Go beyond exit 2: return a structured decision in JSON, always fail on the safe side, and treat your hook like the production code it is.

↓ scroll to study

01 Beyond exit 2: JSON decisions

An exit code is quick, but crude: it only blocks or allows, and the reason is unstructured text in stderr. There’s a second way—your hook prints a Decision JSON on the stdout, and exits with exit 0 even when it's blocking:

{
  "decision": "approve" | "block",
  "reason": "string",
  "hookSpecificOutput": {
    "permissionDecision": "allow" | "deny" | "ask"
  }
}

Notice the difference: in the exit code flow, exit 2 It's the decision. In the JSON path, exit 0 is almost always the answer—the deciding factor is the content printed to stdout.

Test yourself

In the decision JSON flow, what code does the hook usually exit with — even when it’s blocking the action?

02 permissionDecision: allow, ask, deny

Inside hookSpecificOutput, the field permissionDecision has three stances — and one permissionDecisionReason to explain which one and why:

{ "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "push --force na main não é permitido por aqui" } }
  • allow — allows it without asking (useful for reducing friction in a case you already trust).
  • deny — blocks, equivalent to exit 2, but with a structured reason.
  • ask — pauses and asks for confirmation to the human. Use this when the right choice depends on context only you have.

Test yourself

What value of permissionDecision PAUSE the action and ask the human for confirmation instead of deciding on your own?

03 fail-open: never get blocked by your own bug

A hook is just a script — and scripts break (the jq fails, a variable comes back empty, a syntax error occurs). The question that matters: when your own hook has a bug, what should happen? The right answer is fail-open: allow it and notify, never block the entire project because of an error that isn’t the user’s fault.

# right at the top of the script
set +e
trap 'exit 0' ERR
# … rest of the hook …

O trap ensures that if something inside the script blows up, the hook still exits with exit 0 — Claude keeps working, and you can investigate the bug later without stopping anyone midstream. Fail-open isn’t an excuse to write the guard carelessly—it’s a safety net for when it fails in a way you didn’t anticipate.

04 Hooks are powerful: quoting, validation, and keeping secrets from leaking

A hook runs with all your user permissions — it isn’t a sandbox. Treat it as production code, with four hygiene rules:

  • Quoting — always "$var" in quotes; never interpolate again tool_input directly in a new shell.
  • Validate paths — check that a file_path doesn't leave the project (without ../ escaping out).
  • Protect secrets — .env, credentials, .ssh, .git, private keys should never appear in any log, not even in the printed reason.
  • Fail-open — as in the previous step: your bug shouldn't become a permanent blocker.

These four rules fit in one sentence: a fast, idempotent hook that doesn’t leak secrets—wrapping up this track and becoming a habit from here on.

Going deeper

Tool names are case-sensitive (Bash, Edit, Read, Write), a hook’s timeout is in seconds (the generous default is 600s for commands—it’s worth shortening), and hooks can’t be interactive—all these gotchas point in the same direction: write short, predictable hooks that fail safely.

Practice now 0/4 done

Capstone: a guard that returns allow/ask/deny

Replace the raw exit 2 with a structured decision: the same target as Lesson 1 (rm -rf), now returning deny with the reason via JSON on stdout. ~7 min.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "cmd=$(jq -r '.tool_input.command'); echo \"$cmd\" | grep -qE 'rm -rf' && printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"trava: rm -rf bloqueado\"}}'; exit 0" }
        ]
      }
    ]
  }
}

✓ End of the track: you went from exit 2 raw into a guard that speaks the allow/ask/deny language — the same lock, now with an explicit decision and a reason Claude can actually read.