Claude Code Hooks Implementation: Audit System
In today's complex development environments, accountability is essential. This guide walks you through creating a robust audit system that automatically logs all interactions, generates structured audit trails for SOX/SOC2 compliance, and provides complete traceability.
In This Guide
- 1. Understanding hook event types and architecture
- 2. Step-by-step implementation of the audit logger
- 3. Creating slash commands for session management
- 4. Enterprise best practices and compliance
Understanding Hook Event Types
Claude Code hooks are your entry point for intercepting actions inside the development environment. Think of them as checkpoints that let you run custom logic before or after a tool executes. This forms the bedrock of our audit system.
Key Hook Events for Auditing
Critical Note: Official Claude Code documentation shows that hooks receive a JSON payload via stdin. They do not use environment variables for context.
Hook Architecture and Response
When an action triggers our PreToolUse hook, the system pauses and waits for
our script's response. Our script logs the action and then tells Claude to proceed.
To allow execution, our script simply needs to print {"continue": true} to
standard output. Simple, right?
Step 1: Create the Hook Directory Structure
First, set up the directories for your global hooks and local project logs:
# Create the global Claude hooks directory
mkdir -p ~/.claude/hooks
# Create project-specific audit directories
mkdir -p debugging/audit
mkdir -p debugging/audit/archive Step 2: Create the Audit Logger Script
This script is the heart of our system. It captures every tool invocation, formats the data, and writes it to our active log file.
# Create the script file
touch ~/.claude/hooks/audit_logger.sh
# Make it executable
chmod +x ~/.claude/hooks/audit_logger.sh The Complete Audit Logger Script
Create ~/.claude/hooks/audit_logger.sh:
#!/usr/bin/env bash
set -e
# Function to get the current audit file
get_current_audit_file() {
if [ -f "$HOME/.claude/current_audit_file" ]; then
cat "$HOME/.claude/current_audit_file"
else
# Create default audit file if none exists
TIMESTAMP=$(date +"%Y_%m_%d-%H_%M")
DEFAULT_AUDIT="debugging/audit/audit_$TIMESTAMP-default.md"
echo "$DEFAULT_AUDIT" > "$HOME/.claude/current_audit_file"
echo "$DEFAULT_AUDIT"
fi
}
# Function to ensure audit file exists with header
ensure_audit_file() {
local AUDIT_FILE="$1"
if [ ! -f "$AUDIT_FILE" ]; then
mkdir -p "$(dirname "$AUDIT_FILE")" 2>/dev/null || true
{
echo "# Audit Log"
echo
echo "**Session Started:** $(date '+%Y-%m-%d %H:%M:%S')"
echo "**User:** $(whoami)"
echo "**Working Directory:** $(pwd)"
echo "**Project:** $(basename "$(pwd)")"
echo
echo "---"
echo
echo "## Activity Log"
echo
} > "$AUDIT_FILE"
fi
}
# Read input from stdin
INPUT=$(cat)
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
# Get audit file and ensure it exists
AUDIT_FILE=$(get_current_audit_file)
ensure_audit_file "$AUDIT_FILE"
# Extract tool information using jq
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // "Unknown"')
# Log different tool types
case "$TOOL_NAME" in
"Bash")
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
DESCRIPTION=$(echo "$INPUT" | jq -r '.tool_input.description // ""')
{
echo "### Tool Invocation: $TOOL_NAME"
echo "**Timestamp:** $TIMESTAMP"
echo
echo "**Command:**"
echo '```bash'
echo "$COMMAND"
echo '```'
[ -n "$DESCRIPTION" ] && echo "**Purpose:** $DESCRIPTION"
echo
} >> "$AUDIT_FILE"
;;
"Edit"|"MultiEdit"|"Write")
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
{
echo "### File Operation: $TOOL_NAME"
echo "**Timestamp:** $TIMESTAMP"
echo "**Target File:** \`$FILE_PATH\`"
echo
} >> "$AUDIT_FILE"
;;
"Read")
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
{
echo "### File Read: $TOOL_NAME"
echo "**Timestamp:** $TIMESTAMP"
echo "**File:** \`$FILE_PATH\`"
echo
} >> "$AUDIT_FILE"
;;
"Grep"|"Glob")
PATTERN=$(echo "$INPUT" | jq -r '.tool_input.pattern // ""')
{
echo "### Search Operation: $TOOL_NAME"
echo "**Timestamp:** $TIMESTAMP"
echo "**Pattern:** \`$PATTERN\`"
echo
} >> "$AUDIT_FILE"
;;
"Task")
DESCRIPTION=$(echo "$INPUT" | jq -r '.tool_input.description // ""')
{
echo "### Agent Launch: $TOOL_NAME"
echo "**Timestamp:** $TIMESTAMP"
[ -n "$DESCRIPTION" ] && echo "**Description:** $DESCRIPTION"
echo
} >> "$AUDIT_FILE"
;;
*)
# Generic logging for other tools
{
echo "### Tool Usage: $TOOL_NAME"
echo "**Timestamp:** $TIMESTAMP"
echo
} >> "$AUDIT_FILE"
;;
esac
# Always allow execution to continue
echo '{"continue": true}' Understanding the Script
Key Components
get_current_audit_file- Checks for an active session file; creates default if none existsensure_audit_file- Creates the Markdown log file with a header if it's the first entryINPUT=$(cat)- Reads the JSON data that Claude pipes via stdinjq -r '.tool_name'- Parses JSON to extract the tool's name and inputecho '{"continue": true}'- Signals Claude that the tool execution can proceed
Step 3: Create Slash Commands
Slash commands make managing audit sessions easy. Create these in your project's
.claude/commands/ directory.
The /audit Command
Create .claude/commands/audit.md:
---
description: Start a new audit file with an activity name/label
allowed-tools: Bash(date:*), Bash(echo:*), Bash(mkdir:*), Write
---
## Task
Create a new audit file for tracking all activities with the specified activity name.
### Parsed Arguments
Activity name: {{ARG1:activity_name}}
### Implementation
```bash
TIMESTAMP=$(date +"%Y_%m_%d-%H_%M")
ACTIVITY_NAME="{{activity_name}}"
SAFE_ACTIVITY=$(echo "$ACTIVITY_NAME" | tr ' ' '_' | tr -cd '[:alnum:]_-')
AUDIT_FILE="debugging/audit/audit_$TIMESTAMP-$SAFE_ACTIVITY.md"
mkdir -p debugging/audit
echo "$AUDIT_FILE" > ~/.claude/current_audit_file
cat > "$AUDIT_FILE" << EOF
# Audit Log
**Session Started:** $(date '+%Y-%m-%d %H:%M:%S')
**Activity:** $ACTIVITY_NAME
**User:** $(whoami)
**Working Directory:** $(pwd)
---
## Activity Log
EOF
echo "Audit file created: $AUDIT_FILE"
echo "Auditing is now active for: $ACTIVITY_NAME"
``` The /audit-archive Command
Create .claude/commands/audit-archive.md:
---
description: Archive all but the most current audit file
allowed-tools: Bash(ls:*), Bash(mv:*), Bash(mkdir:*)
---
## Task
Move all audit files except the most recent one to the archive directory.
### Implementation
```bash
mkdir -p debugging/audit/archive
AUDIT_FILES=($(ls -t debugging/audit/audit_*.md 2>/dev/null))
if [ ${#AUDIT_FILES[@]} -le 1 ]; then
echo "Nothing to archive"
exit 0
fi
CURRENT_FILE="${AUDIT_FILES[0]}"
echo "Keeping current: $(basename $CURRENT_FILE)"
for i in "${!AUDIT_FILES[@]}"; do
if [ $i -eq 0 ]; then continue; fi
FILE="${AUDIT_FILES[$i]}"
mv "$FILE" "debugging/audit/archive/"
echo "Archived: $(basename $FILE)"
done
echo "Archive complete"
``` Step 4: Configure the Hook
Add the hook configuration to your project's .claude/settings.local.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/audit_logger.sh"
}
]
}
]
}
}
The "matcher": ".*" ensures the hook runs before every tool invocation.
Testing Your Audit System
- Start a Session: Run
/audit test-runin Claude - Run Commands: Execute a few shell commands like
ls -la - Check the Log: Open the new
audit_*.mdfile indebugging/audit/ - Archive Logs: Run
/audit-archiveto tidy up old logs
Sample Log Output
# Audit Log
**Session Started:** 2025-09-26 22:10:15
**Activity:** test-run
**User:** developer
**Working Directory:** /home/developer/project
---
## Activity Log
### Tool Invocation: Bash
**Timestamp:** 2025-09-26 22:10:16
**Command:**
```bash
ls -la
```
### File Read: Read
**Timestamp:** 2025-09-26 22:10:18
**File:** `src/main.py` Troubleshooting Common Issues
Script Not Executable
Your script must have execute permissions:
chmod +x ~/.claude/hooks/audit_logger.sh
jq Not Installed
The logger depends on jq for JSON parsing:
brew install jq # macOS apt install jq # Ubuntu/Debian
Incorrect JSON in settings.local.json
Validate your JSON with a linter. Misplaced commas cause silent failures.
Enterprise Best Practices
For teams requiring SOX or SOC2 compliance:
Security
- Sanitize inputs before logging
- Redact sensitive data (API keys, passwords)
- Use file permissions for log access control
Immutability
- Set archived logs to read-only (
chmod 444) - Use append-only storage if available
- Hash logs for integrity verification
SIEM Integration
- Send logs to Splunk/Datadog
- Enable real-time alerting
- Centralize multi-project logs
Retention
- Define retention policies
- Automate archival to cold storage
- Maintain audit trail of deletions
Comprehensive Multi-Hook Example
For production use, you might want multiple hooks working together. Here's a complete configuration that logs pre-execution, post-execution, and session end:
{
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/log_pre_tool.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/log_post_tool.sh"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/log_stop.sh"
}
]
}
]
}
} Continue Your Journey
With your audit system in place, you've transformed Claude into a fully accountable development partner. Explore these related topics:
- Hooks Introduction - Review the fundamentals
- Skills Guide Part 1 - Combine hooks with skills
- Browse Skills - See audit patterns in community skills
Complete Traceability
With this system in place, you've transformed your AI assistant into a reliable, enterprise-ready partner with full accountability. No more mystery operations. Just clear, undeniable proof of every action.