1
0
Fork 0

feat: initial companion repo for CC-024 Hooks

This commit is contained in:
Kjell Tore Guttormsen 2026-03-30 10:38:57 +02:00
commit cd834a7476
7 changed files with 790 additions and 0 deletions

51
CLAUDE.md Normal file
View file

@ -0,0 +1,51 @@
# Hooks: Run Code When Claude Code Acts
## What this project is
A companion repo for the From AI to Chitta article on **Hooks**
(concept ID: `CC-024`). It contains working hook scripts and exercises
that let you set up real guardrails and audit logging in Claude Code.
Article: https://fromaitochitta.com/cc-024
## How to use this
1. Open Claude Code in this directory: `cd claude-code-hooks && claude`
2. Work through exercises in order: `exercises/01-block-danger.md`, then 02, then 03
3. Each exercise has concrete steps with expected output
4. Compare your results to the expected output described in each exercise
## What is configured
- **Pre-tool-use hook** (`hooks/pre-tool-use.sh`): Blocks a set of dangerous
shell commands before Claude executes them. Reads JSON from stdin, matches
against blocked patterns, exits 2 to block or 0 to allow.
- **Post-tool-use hook** (`hooks/post-tool-use.sh`): Logs every tool call to
`hooks/audit.log` with a timestamp, tool name, and input summary.
- **Settings** (`.claude/settings.json`): Wires both hooks into Claude Code.
## Code quality expectations
- This is a learning repo. Clarity over cleverness.
- Every exercise must run without modification on a clean clone
- No external dependencies beyond Python 3 (standard on macOS)
- If something does not work as expected, that is useful information: note it
## Project structure
```
hooks/ pre-tool-use.sh, post-tool-use.sh, audit.log (generated)
exercises/ Three numbered exercises with steps and expected output
.claude/ settings.json with hook configuration
CLAUDE.md This file: project instructions
README.md Overview for anyone who clones this repo
```
## Domain
Claude Code Automation
## Memory
Keep session notes in a local `MEMORY.md` (gitignored). This file
(CLAUDE.md) is for instructions. MEMORY.md is for state.

70
README.md Normal file
View file

@ -0,0 +1,70 @@
# Hooks: Run Code When Claude Code Acts
Control what Claude Code can and cannot do by running your own scripts
before and after every tool call. Hooks are the mechanism behind security
guardrails, audit trails, and AI-powered approval workflows.
Companion repo for the article
[Hooks: Run Code When Claude Code Acts](https://fromaitochitta.com/cc-024) on From AI to Chitta.
---
## What You Will Learn
By working through the exercises in this repo, you will be able to:
- Block dangerous commands with pre-tool-use hooks before they ever execute
- Build automatic audit trails with post-tool-use hooks that log every action
- Create prompt-based hooks where Claude evaluates a decision before proceeding
These are not theoretical outcomes. Complete the exercises and you will
have working hooks you can configure in any Claude Code project.
## Prerequisites
- Claude Code installed and running (`claude --version` prints a version)
- Basic shell scripting knowledge (read a bash script, understand exit codes)
- Python 3 (for JSON parsing in the hook scripts; comes with macOS)
No npm install, no Docker, no build step.
## Exercises
| # | Exercise | What It Covers |
|---|----------|---------------|
| 01 | [Block Dangerous Commands](exercises/01-block-danger.md) | PreToolUse hooks, exit codes, blocking behavior |
| 02 | [Build an Audit Trail](exercises/02-audit-trail.md) | PostToolUse hooks, append-only logging |
| 03 | [Prompt-Based Approval](exercises/03-prompt-hooks.md) | AI-powered hooks, risk evaluation before acting |
Start at `exercises/01-block-danger.md`. Each exercise links to the next.
## How to Use This Repo
**"Show me what it does":** Read through `hooks/pre-tool-use.sh` and
`hooks/post-tool-use.sh`. Then read `.claude/settings.json` to see how
they are wired in. The pattern becomes clear before you run anything.
**"Let me actually do it":** Open Claude Code in this directory and
follow the exercises in order. Each one has an expected output section
so you know when you have succeeded.
```bash
git clone https://git.fromaitochitta.com/fromaitochitta/claude-code-hooks.git
cd claude-code-hooks
claude
```
Then open `exercises/01-block-danger.md` and follow the steps.
## Domain
**Claude Code Automation** - This repo is part of the Claude Code Automation series on
From AI to Chitta. See the full series for related concepts.
## About
Built by [Kjell Tore Guttormsen](https://fromaitochitta.com) as
part of the From AI to Chitta project, exploring the intersection
of AI tools and inner development.
MIT License.

View file

@ -0,0 +1,158 @@
# Exercise 01: Block Dangerous Commands
**Concept:** Hooks (CC-024)
**Level:** Basic
**Time:** ~10 minutes
---
## Objective
Set up a pre-tool-use hook that blocks dangerous shell commands before
Claude executes them. You will configure the hook, test it by asking
Claude to run a blocked command, and confirm the block worked.
---
## Before You Start
Confirm you have:
- [ ] Claude Code installed (`claude --version` prints a version)
- [ ] This repo cloned and open in Claude Code
- [ ] Python 3 available (`python3 --version`)
---
## Instructions
**Step 1:** Read the hook script.
Open `hooks/pre-tool-use.sh` and read through it. Pay attention to:
- How it reads input from stdin using `cat`
- How it extracts `tool_name` and `command` from the JSON
- What the `BLOCKED` array contains
- What exit code 2 means vs exit code 0
You do not need to modify the script. Understanding it is the goal of this step.
**Step 2:** Make the script executable.
```bash
chmod +x hooks/pre-tool-use.sh
```
This is required before Claude Code can run it as a hook.
**Step 3:** Configure the hook in `.claude/settings.json`.
Create the file `.claude/settings.json` in this repo with this content:
```json
{
"permissions": {
"allow": [
"Bash(ls:*)",
"Bash(cat:*)",
"Bash(echo:*)",
"Bash(pwd)",
"Bash(date)",
"Read",
"Write",
"Edit",
"Glob",
"Grep"
],
"deny": [
"Bash(rm -rf *)",
"Bash(sudo *)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash hooks/pre-tool-use.sh"
}
]
}
]
}
}
```
The `matcher` field is a regex pattern matched against the tool name.
`"Bash"` matches the Bash tool. `".*"` would match all tools.
The `command` field is the shell command Claude Code runs before
executing the tool. It must exit 0 to allow, 2 to block.
**Step 4:** Start Claude Code in this directory.
```bash
cd claude-code-hooks
claude
```
Claude Code reads `.claude/settings.json` on startup and registers the hooks.
**Step 5:** Test the block by asking Claude to run a dangerous command.
Paste this prompt into Claude Code:
```
Run this shell command: sudo ls /root
```
Watch what happens. Claude will attempt the Bash tool call. The
pre-tool-use hook will run first, match the `sudo` pattern, and exit 2.
**Step 6:** Verify the result.
After the hook blocks the command, paste this prompt:
```
Did the command succeed? Why or why not?
```
Claude should explain that the command was blocked by a hook and show
the reason returned by the script.
---
## Expected Output
After completing the steps, you should see:
- Claude attempting the `sudo ls /root` command
- An error message from the hook: `Commands with sudo are not allowed`
- Claude reporting that the command was blocked and could not proceed
- No output from `sudo ls /root` because it never ran
If Claude executes the command anyway, check that `pre-tool-use.sh` is
executable and that `.claude/settings.json` has the correct hook config.
---
## What You Learned
- **PreToolUse hooks intercept before execution.** The command never runs
if the hook exits 2. This is different from permission deny lists, which
are pattern-matched by Claude Code before the tool call. Hooks let you
run arbitrary logic.
- **Exit code 2 means block.** Exit 0 means allow. Any other exit code is
treated as an error and may allow or block depending on Claude Code's
configuration.
- **The reason field reaches Claude.** Whatever you put in the `reason`
field of the JSON output, Claude receives it as the error message. This
lets you explain why a command was blocked in human-readable terms.
---
## Next
Move to [Exercise 02: Build an Audit Trail](./02-audit-trail.md).

173
exercises/02-audit-trail.md Normal file
View file

@ -0,0 +1,173 @@
# Exercise 02: Build an Audit Trail
**Concept:** Hooks (CC-024)
**Level:** Intermediate
**Time:** ~15 minutes
---
## Objective
Add a post-tool-use hook that logs every tool call Claude makes to a
local file. You will give Claude a multi-step task, then read the audit
log to see everything it did.
This is the foundation for compliance logging, debugging, and
understanding what Claude actually does during a session.
---
## Before You Start
Confirm you have completed Exercise 01, or at minimum:
- [ ] `hooks/post-tool-use.sh` exists in this repo
- [ ] `.claude/settings.json` exists with the PreToolUse hook from Exercise 01
---
## Instructions
**Step 1:** Read the post-tool-use hook.
Open `hooks/post-tool-use.sh` and read through it. Notice:
- It always exits 0. Post-tool-use hooks cannot block.
- It extracts different keys depending on the tool (`command`, `file_path`, `pattern`)
- It appends to `hooks/audit.log` in the same directory as the script itself
- The timestamp format is UTC ISO 8601
**Step 2:** Make the script executable.
```bash
chmod +x hooks/post-tool-use.sh
```
**Step 3:** Add the PostToolUse hook to `.claude/settings.json`.
Update your `.claude/settings.json` to include both hooks:
```json
{
"permissions": {
"allow": [
"Bash(ls:*)",
"Bash(cat:*)",
"Bash(echo:*)",
"Bash(pwd)",
"Bash(date)",
"Read",
"Write",
"Edit",
"Glob",
"Grep"
],
"deny": [
"Bash(rm -rf *)",
"Bash(sudo *)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash hooks/pre-tool-use.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "bash hooks/post-tool-use.sh"
}
]
}
]
}
}
```
The `matcher: ".*"` pattern matches every tool, not just Bash. This
means every Read, Write, Grep, and WebSearch call will be logged.
**Step 4:** Start or restart Claude Code.
```bash
cd claude-code-hooks
claude
```
If Claude Code is already running, restart it so it picks up the new
settings file.
**Step 5:** Give Claude a multi-step task.
Paste this prompt into Claude Code:
```
List the files in this directory, then read README.md, then tell me
how many lines it has.
```
This will trigger multiple tool calls: a Bash call for `ls`, a Read
call for README.md, and a Bash call for `wc -l`.
**Step 6:** Read the audit log.
After Claude finishes, paste this prompt:
```
Show me the contents of hooks/audit.log
```
Or read it directly from your terminal:
```bash
cat hooks/audit.log
```
---
## Expected Output
The audit log should contain entries like:
```
2026-03-30T04:12:33Z | Bash | ls -la
2026-03-30T04:12:34Z | Read | README.md
2026-03-30T04:12:35Z | Bash | wc -l README.md
```
Each line shows the UTC timestamp, the tool name, and the primary
argument (command, file path, or search pattern).
If `audit.log` does not exist after the task, check that `post-tool-use.sh`
is executable and that the `PostToolUse` hook is in `settings.json`.
---
## What You Learned
- **PostToolUse hooks run after every tool call.** They receive the same
JSON input as PreToolUse hooks but cannot influence the outcome. Their
job is observation, not control.
- **`matcher: ".*"` captures everything.** Narrowing the matcher to `"Bash"`
would log only shell commands. Using `".*"` logs all tool types.
- **Audit logs survive sessions.** The log file persists between Claude
Code sessions. This makes it useful for compliance and debugging:
you can always reconstruct what happened.
- **Logs are per-project.** The log path is relative to the script, so
each project has its own audit trail.
---
## Next
Move to [Exercise 03: Prompt-Based Approval](./03-prompt-hooks.md).

View file

@ -0,0 +1,215 @@
# Exercise 03: Prompt-Based Approval
**Concept:** Hooks (CC-024)
**Level:** Advanced
**Time:** ~20 minutes
---
## Objective
Create a hook where Claude itself evaluates whether to proceed with
a file edit. Instead of a static blocklist, the hook sends a prompt
to Claude asking "is this change risky?" and uses the answer to
allow or block the action.
This is AI-powered guardrails: one Claude instance watches another.
---
## Before You Start
Confirm you have completed Exercises 01 and 02, or at minimum:
- [ ] Claude Code installed with an API key configured
- [ ] `.claude/settings.json` exists in this repo
- [ ] You understand how exit codes control hook decisions
---
## Background: Prompt Hooks
Claude Code supports three hook types:
| Type | What it does |
|------|-------------|
| `command` | Runs a shell script. Exit 0 to allow, 2 to block. |
| `prompt` | Sends a prompt to Claude. Claude responds with `allow` or `block`. |
Prompt hooks let you express approval logic in plain language rather
than regex patterns or shell conditionals. The tradeoff: they cost
API tokens and add latency to every hook invocation.
The prompt hook configuration looks like this:
```json
{
"type": "prompt",
"prompt": "The user asked Claude to make the following file edit:\n\n{tool_input}\n\nDoes this edit look like it could break existing functionality? Respond with exactly 'allow' or 'block'."
}
```
The `{tool_input}` placeholder is replaced with the serialized tool
input at runtime.
---
## Instructions
**Step 1:** Understand what you are building.
You will add a prompt hook on the Write tool. Before Claude writes
any file, the hook will ask: "Is this edit likely to break existing
tests or functionality?"
If Claude answers `block`, the write is prevented. If it answers
`allow`, the write proceeds.
This is a demonstration of the concept, not a production guardrail.
Real prompt hooks would use a more precise prompt and probably
target specific file patterns (e.g., only `*.test.*` files).
**Step 2:** Update `.claude/settings.json` with the prompt hook.
Add a `PreToolUse` entry for the Write tool:
```json
{
"permissions": {
"allow": [
"Bash(ls:*)",
"Bash(cat:*)",
"Bash(echo:*)",
"Bash(pwd)",
"Bash(date)",
"Read",
"Write",
"Edit",
"Glob",
"Grep"
],
"deny": [
"Bash(rm -rf *)",
"Bash(sudo *)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash hooks/pre-tool-use.sh"
}
]
},
{
"matcher": "Write",
"hooks": [
{
"type": "prompt",
"prompt": "Claude is about to write a file. The file path and content are:\n\n{tool_input}\n\nDoes this write look like it could overwrite important existing content or break existing functionality? If it is writing to a new file or making a clearly safe addition, respond 'allow'. If it is overwriting a file in a way that seems risky or destructive, respond 'block'. Respond with exactly one word: allow or block."
}
]
}
],
"PostToolUse": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "bash hooks/post-tool-use.sh"
}
]
}
]
}
}
```
**Step 3:** Restart Claude Code to load the new settings.
```bash
claude
```
**Step 4:** Ask Claude to make a safe write.
Paste this prompt:
```
Write a file called scratch.txt with the content: "hello world"
```
This is a new file with harmless content. The prompt hook should
evaluate it as safe and respond `allow`. The write proceeds.
**Step 5:** Ask Claude to make a risky write.
Paste this prompt:
```
Overwrite README.md with a single line: "This file has been cleared."
```
This destroys existing content. The prompt hook should evaluate
it as risky and respond `block`. Claude will report that the write
was blocked.
**Step 6:** Review what happened.
Check `hooks/audit.log` to see the tool calls that were logged,
including the ones the prompt hook evaluated.
```bash
cat hooks/audit.log
```
Note that the blocked write does not appear in the log (it never
ran), but the audit log still captures the preceding tool calls.
---
## Expected Output
After Step 4: `scratch.txt` is created. Claude confirms the write succeeded.
After Step 5: The write is blocked. Claude reports that the hook
prevented it from overwriting `README.md`. The message from the
prompt hook will indicate the block decision.
If the risky write is allowed, the prompt hook may have evaluated
the edit differently. This is expected: prompt hooks are probabilistic.
Try refining the prompt to be more explicit about what counts as risky.
---
## What You Learned
- **Prompt hooks add AI judgment to the pipeline.** Instead of matching
patterns, you describe the decision in plain language. Claude reads
the context and decides.
- **The `{tool_input}` placeholder carries the full context.** The entire
tool input is available to the evaluating Claude, including file paths
and content. This gives the hook genuine information to reason from.
- **Prompt hooks are latency-sensitive.** Each invocation makes an API
call. Apply them selectively to high-risk operations, not every tool.
- **Prompt hooks are not deterministic.** The same input can produce
different outputs. For hard security requirements, use command hooks
with explicit patterns. Prompt hooks are best for nuanced judgment calls.
---
## Going Further
A few directions worth exploring once you have the basics working:
- Narrow the Write hook matcher to only trigger on specific paths:
`"matcher": "Write.*\\.py$"` (Python files only)
- Add a PostToolUse prompt hook that summarizes what changed each session
- Combine command hooks for hard blocks with prompt hooks for soft approvals
The full Claude Code hooks documentation is at:
https://docs.anthropic.com/en/docs/claude-code/hooks

51
hooks/post-tool-use.sh Normal file
View file

@ -0,0 +1,51 @@
#!/bin/bash
# post-tool-use.sh: Append an audit log entry after every tool call.
#
# Claude Code calls this script AFTER every tool execution.
# The script receives a JSON object on stdin with two fields:
# - tool_name: the name of the tool that ran (e.g., "Bash", "Read", "Write")
# - tool_input: the arguments passed to the tool
#
# This script writes one line to hooks/audit.log:
# 2026-03-30T04:12:33Z | Bash | ls -la /tmp
#
# The log is append-only. It grows during the session and persists between sessions.
# Read it any time with: cat hooks/audit.log
#
# Exit code:
# 0 Always. This hook does not block anything.
#
# Make this script executable: chmod +x hooks/post-tool-use.sh
# Read the full JSON payload from stdin
input=$(cat)
# Extract the tool name
tool_name=$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_name','unknown'))" 2>/dev/null || echo "unknown")
# Extract the most relevant part of the tool input.
# Different tools use different argument keys:
# Bash -> command
# Read -> file_path
# Write -> file_path
# Grep -> pattern
# Fall back to a truncated string representation of the entire input dict.
tool_input=$(echo "$input" | python3 -c "
import sys, json
d = json.load(sys.stdin).get('tool_input', {})
value = d.get('command') or d.get('file_path') or d.get('pattern') or str(d)[:120]
print(value)
" 2>/dev/null || echo "(parse error)")
# Timestamp in UTC ISO 8601
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Write the log directory relative to this script's location.
# This keeps the log inside the repo regardless of where Claude Code is invoked from.
log_dir="$(dirname "$0")"
log_file="$log_dir/audit.log"
# Append one line to the log file
echo "$timestamp | $tool_name | $tool_input" >> "$log_file"
exit 0

72
hooks/pre-tool-use.sh Normal file
View file

@ -0,0 +1,72 @@
#!/bin/bash
# pre-tool-use.sh: Block dangerous shell commands before Claude executes them.
#
# Claude Code calls this script BEFORE executing any Bash command.
# The script receives a JSON object on stdin with two fields:
# - tool_name: the name of the tool being called (e.g., "Bash")
# - tool_input: the arguments passed to the tool (e.g., {"command": "ls -la"})
#
# Exit codes:
# 0 Allow the command to proceed
# 2 Block the command (Claude receives the "reason" field as an error message)
#
# When blocking, write a JSON object to stdout:
# {"decision": "block", "reason": "Human-readable explanation"}
#
# Make this script executable: chmod +x hooks/pre-tool-use.sh
# Read the full JSON payload from stdin
input=$(cat)
# Extract tool_name and the command argument from the JSON
tool_name=$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null)
command=$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null)
# Only inspect Bash tool calls. Allow everything else immediately.
if [ "$tool_name" != "Bash" ]; then
exit 0
fi
# Each blocked pattern is checked separately with a descriptive reason.
# Pattern matching uses grep -qiE (case-insensitive extended regex).
block_if_match() {
local pattern="$1"
local reason="$2"
if echo "$command" | grep -qiE "$pattern"; then
echo "{\"decision\": \"block\", \"reason\": \"$reason\"}"
exit 2
fi
}
# Recursive deletion of the root filesystem
block_if_match 'rm -rf /' 'Recursive deletion from root is not allowed'
# sudo: prevents privilege escalation
block_if_match 'sudo ' 'Commands with sudo are not allowed'
# World-writable permissions: a common misconfiguration that opens security holes
block_if_match 'chmod 777' 'chmod 777 is not allowed: use more restrictive permissions'
# Direct disk write: can wipe an entire disk in seconds
block_if_match 'dd if=' 'Direct disk writes with dd are not allowed'
# Filesystem formatting: permanently destroys data
block_if_match 'mkfs' 'Formatting filesystems is not allowed'
# Fork bomb: crashes the system by spawning processes until resources are exhausted
block_if_match ':\(\)\{' 'Fork bombs are not allowed'
# Piping curl or wget output directly into a shell:
# executes arbitrary remote code without review
block_if_match 'curl.+\|.+bash' 'Piping curl into bash is not allowed'
block_if_match 'curl.+\|.+sh' 'Piping curl output into a shell is not allowed'
block_if_match 'wget.+\|.+bash' 'Piping wget into bash is not allowed'
block_if_match 'wget.+\|.+sh' 'Piping wget output into a shell is not allowed'
# Shutdown and reboot: should never be triggered by an automated agent
block_if_match '^shutdown' 'Shutdown commands are not allowed'
block_if_match '^reboot' 'Reboot commands are not allowed'
# Nothing matched: allow the command
exit 0