commit cd834a747630d4520ed78b192696b41817bea532 Author: Kjell Tore Guttormsen Date: Mon Mar 30 10:38:57 2026 +0200 feat: initial companion repo for CC-024 Hooks diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6fab1f6 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..18b6773 --- /dev/null +++ b/README.md @@ -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. diff --git a/exercises/01-block-danger.md b/exercises/01-block-danger.md new file mode 100644 index 0000000..a02c9cc --- /dev/null +++ b/exercises/01-block-danger.md @@ -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). diff --git a/exercises/02-audit-trail.md b/exercises/02-audit-trail.md new file mode 100644 index 0000000..1afd75b --- /dev/null +++ b/exercises/02-audit-trail.md @@ -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). diff --git a/exercises/03-prompt-hooks.md b/exercises/03-prompt-hooks.md new file mode 100644 index 0000000..6cd5f3b --- /dev/null +++ b/exercises/03-prompt-hooks.md @@ -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 diff --git a/hooks/post-tool-use.sh b/hooks/post-tool-use.sh new file mode 100644 index 0000000..8490258 --- /dev/null +++ b/hooks/post-tool-use.sh @@ -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 diff --git a/hooks/pre-tool-use.sh b/hooks/pre-tool-use.sh new file mode 100644 index 0000000..6583940 --- /dev/null +++ b/hooks/pre-tool-use.sh @@ -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