PTENES
INEMA.CLUBPROClaude Code Hooks

Claude Code Hooks / Track 04

Track 04 · Team & CI

Team & CI

What changes when the hook leaves your machine: versioning it for the team, relying on determinism, running it unattended (headless/CI), and the hygiene needed to avoid getting hurt by a hook’s power.

4lessons
~36minutes
advancedlevel

Track 04 · Lesson 1 · Advanced

Version control
to team

A hook that exists only on your machine protects no one but you — it's time to make it travel with the repository.

↓ scroll to study

01 settings.json in Git

Up to this point, every hook you built ran on your machine. But the .claude/settings.json is a regular file inside the project folder—and a regular file can go to Git.

When it is versioned (committed, with git add), it stops being "Nei’s hook" and becomes the project hook. It starts traveling with the code, in the same commit as the guard or formatter it protects.

Test yourself

To make a hook belong to the project instead of just you, what changes?

02 The whole team inherits the same rules

With the settings.json versioned, the effect is automatic: whoever clones the repository or runs git pull, receives the file—and Claude Code reads the same hooks. No need to ask anyone to install anything or paste instructions into any README.

This changes the bar for what’s worth automating. A formatter or security guard that only you have protects only you. The same versioned hook protects everyone who touches the code — including those who haven’t even joined the team yet.

Test yourself

If a colleague clones the repository and the .claude/settings.json already has a hook configured, what happens?

03 settings.local.json: outside Git

Not everything belongs to the team. Secrets, personal shortcuts, a path that exists only on your machine — that shouldn’t go into the repository. That’s what the settings.local.json: same format, same folder .claude/, but listed in the .gitignore.

# stays out of the repository
echo ".claude/settings.local.json" >> .gitignore

Rule of thumb: if a hook should apply to anyone that when you open this project, it goes to the settings.json. If it's your preference—or there's something sensitive—it goes in .local.json.

Test yourself

You want a personal tweak, just for THIS project, without putting it in Git. Which file?

04 Precedence: user → project → local

You now have up to three files in effect at the same time: the global (~/.claude/settings.json, all your projects), the of the project (.claude/settings.json, versioned) and the local (.claude/settings.local.json, just yours, only here).

In a conflict, the most specific one wins: local overrides project, which overrides global. Think of it this way: global is the general rule for your house; project is the building rule; local is the sticky note on your door—and the sticky note wins.

Test yourself

When .claude/settings.json (project) and .claude/settings.local.json (local) conflict, which one wins?

Going deeper

This applies tool by tool; it’s not all or nothing: if the global config has a hook in PostToolUse and the project one has another in PreToolUse, both coexist—precedence determines which one wins when there's real conflict in the same rule.

Practice now 0/4 done

Commit the project’s settings.json

Have the team’s standard travel with the actual repository. ~5 min, safe: you’re only versioning a configuration file, not running any destructive command.

# team-owned items go in Git
git add .claude/settings.json
git status
# items that are just yours stay out
echo ".claude/settings.local.json" >> .gitignore
git add .gitignore
git commit -m "chore: versiona hooks do time"

✓ The standard no longer lives only in your head (or only on your machine) — now it lives in the repository and travels with it.

Track 04 · Lesson 2 · Advanced

Determinism:
you can trust

A hook that sometimes hangs and sometimes doesn't is worse than having no hook at all — here's where you learn to trust (and test) what you've automated.

↓ scroll to study

01 Idempotency: running it twice produces the same

A hook is a regular command, and regular commands don't "find" anything — it is idempotent: running the same input twice must produce the same result, without breaking anything the second time.

Practical example: a hook that formats a file can run ten times in a row—the formatted file stays the same when formatted again. But a hook that sum something on every run (like incrementing a counter without checking whether it has already run) isn’t idempotent—and that’s where subtle automation bugs live.

Test yourself

An idempotent hook means that:

02 Timeouts: the field timeout

A hook gets in the way of every action — if it hangs, everything blocks together. That's why each hook has a timeout, in seconds:

{ "hooks": { "PreToolUse": [ {
  "matcher": "Bash",
  "hooks": [ { "type": "command", "command": "./guarda.sh",
    "timeout": 10 } ]
} ] } }

If you don’t define timeout, the pattern for a command hook is 600 seconds (10 minutes)—far too generous for a guard. A quick hook won’t come close; but a hook that makes an uncareful network call can hang your session until the timeout. That’s why it’s worth setting a short limit (e.g., 10) in hooks that are on the path of each action.

Test yourself

If you don’t define the field timeout in a command hook, what's the pattern?

03 Test in isolation: fake stdin

You don't need Claude Code itself to know whether a hook works. Every hook reads the same stdin — so you manually create this JSON and feed it directly to the hook:

# isolated test — without opening Claude Code
echo '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}' \
  | bash .claude/hooks/guarda.sh
echo "Exit: $?"

This is worth its weight in gold: you see exactly what the hook would do, in your terminal, in seconds — without waiting for an entire session just to find out you forgot a quote.

04 Fail-open + log for debugging

A hook that breaks because of an error that you didn’t anticipate shouldn't stall all your work. The rule is fail open: capture the error and let it proceed.

# catches any unexpected error and allows it
set +e
trap 'exit 0' ERR
# ... hook logic ...

And to debug when something goes wrong, run with -x (shows each executed line) piping into a file with the test JSON:

bash -x .claude/hooks/guarda.sh < entrada.json
echo "Exit: $?"

Test yourself

How can you test a hook without Claude actually triggering the event?

Going deeper

"Fail open" (exit 0 on an unexpected error) is different from "fail closed" (exit 2 on purpose). The first is a safety net against a bug in your script; the second is the lock you designed to actually block.

Practice now 0/4 done

Test a hook with fake stdin

Feed a hook fake JSON and confirm it behaves the same way twice. ~5 min, safe: none of this goes anywhere near real Claude Code.

#!/usr/bin/env bash
# .claude/hooks/testa.sh
set +e
trap 'exit 0' ERR
INPUT=$(cat)
CMD=$(printf '%s' "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null)
echo "recebi: $CMD" >> .claude/test.log
exit 0

✓ You tested a hook without needing an entire Claude Code session — and saw determinism working with your own eyes.

Track 04 · Lesson 3 · Advanced

Running without
no one looking

No one is at the screen — but that doesn’t mean hooks take a break: they enforce the rules in headless mode and in CI, too.

↓ scroll to study

01 Headless mode: claude -p

Claude Code also runs without any interface — headless headless, triggered by a script: claude -p "seu comando aqui". It reads the prompt, works, responds, and finishes. No screen, no need for you to watch.

This is the mode used in a deploy script, a cron job, or inside a pipeline. And that's exactly where hooks prove their value.

02 Hooks in CI/pipeline

If the settings.json is versioned (Lesson 1), it applies in anywhere that runs Claude Code—including inside a CI runner, with no one logged in and no screen at all.

The block that prevents rm -rf, the formatter that fixes the code, the log that records commands—all of that stays active. The same pattern that protects you day to day protects the robot too.

Test yourself

In headless mode (claude -p) and within a CI pipeline, the configured hooks:

03 Exit codes and non-interactivity

With no one on screen, the hook can't stop to ask. Hooks aren't interactive: don't wait for a human to type "yes" in the middle of execution. Everything is handled through PreToolUse and by the exit code:

  • exit 0 — continues as usual.
  • exit 2 — blocks (only applies to PreToolUse e Stop); o stderr becomes the reason.
  • exit 1, 3+ — a non-blocking error; Claude continues (fail-open).

In CI, this matters even more: if a hook tried to “ask,” the pipeline would hang forever waiting for an answer that never comes.

Test yourself

Can a hook pause execution and wait for you to type a response in the middle of the process?

Going deeper

In addition to the exit code, a PreToolUse can return JSON with permissionDecision: allow/deny/ask. But ask doesn't make sense in headless mode — there's no one to respond. In CI, always prefer allow/deny explicit.

04 SubagentStop: when a subagent finishes

There’s an event similar to Stop, but one level lower: SubagentStop triggers when a subagent ends its own turn — not the main agent's, but a delegated task's.

Important differences: SubagentStop doesn't block (unlike Stop, which accepts exit 2), and doesn’t use matcher. Treat it as a notification ("this part finished"), not a guard—and don't build critical logic on it alone, since its behavior may be incomplete.

Test yourself

Which event fires when a SUBAGENT (not the main agent) finishes its turn?

Practice now 0/4 done

Run Claude with -p and an active hook

Prove that the hook fires even with no screen at all. ~5 min, safe: the command only lists files.

# the logging hook you already have in .claude/settings.json still applies
cat .claude/settings.json
# run Claude Code with no interface at all
claude -p "liste os arquivos deste diretório"
# check: the hook actually fired in headless mode
cat .claude/test.log

✓ The same hook that protects your day-to-day also protected the automation — without a single extra line of configuration.

Track 04 · Lesson 4 · Advanced

Hygiene:
hook is power

A hook runs with all your power — and power without care is the perfect recipe for an expensive accident.

↓ scroll to study

01 Hooks run with ALL your privileges

A hook isn't a sandbox or a "toy" script — it runs with same permissions as your user: the same shell, the same credentials, the same network access you have when you open the terminal.

This isn’t a bug; it’s why hooks work: to format a file or block a rm -rf, the hook needs to be able to affect the real system. But the same power that protects you can, if poorly written, break — so treat a hook with the respect a full-access script deserves.

Test yourself

When a hook runs, it has access to:

02 Never trust the input

O tool_input that reaches your hook comes from a context you don't fully control. Putting that value directly into a shell command is the classic entry point for shell injection:

# dangerous: interpolates directly into the shell
cmd=$(jq -r '.tool_input.command')
eval "$cmd"  ← DON'T do this

# better: treat it as data and validate it before acting
cmd=$(jq -r '.tool_input.command')
echo "$cmd" | grep -qE 'rm -rf|:\(\)\{' && { echo "bloqueado" >&2; exit 2; }
exit 0

The same care applies to file paths: always validate (without ../ (leaving the project folder) before reading or writing— path traversal is the same problem, but with files instead of commands. And deliberately protect what's sensitive: .env, .ssh, .git, private keys.

Test yourself

Why should you NEVER interpolate tool_input directly into a shell command (like eval "$cmd")?

03 PreCompact and state cleanup

There’s an event called PreCompact, tied to the moment when the conversation context is compacted. The idea is to give you a chance to persist or clear state before that happens.

But it’s one of the events less documented officially — don’t assume strong guarantees about exactly when or how it fires. In practice: use it for non-critical things (a log, a backup), always with the same fail-open precautions, and never rely on it alone for project security.

04 Final security checklist

Before considering a hook "ready for the team," go through this list:

  • Quick — doesn’t block the workflow; there’s timeout defined.
  • Idempotent — running it twice doesn’t break anything.
  • Never trusts the input — without eval from a raw string, validated paths.
  • Protects sensitive data — .env, .ssh, .git, keys out of reach of a poorly written hook.
  • No secrets in the command — no token/password written directly in the settings.json (is stored in an environment variable or file).
  • Fail open — an unexpected error becomes exit 0 with logging; it doesn’t block Claude.

Start small and harden it over time. The hook that lasts on your team is the one nobody notices — because it never gets in the way and always protects.

Going deeper

It's worth reviewing hooks from time to time, the way you review access permissions: what was necessary 3 months ago may no longer be, and every automatic command line is one that runs without you checking — the essence of this entire track.

Practice now 0/4 done

Harden your settings.json · capstone

Review the project’s settings.json against the security checklist — correct quotes, validation, no secrets in the command. ~10 min, safe: it’s a review, not running anything destructive.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/guarda.sh\"",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

✓ Checklist complete: correct quotes, no secrets in the command, timeout set, script fails open. That’s maturity — a reliable hook is one that never hurts you. End of the track. 🎉