DEV Community

AgentKit
AgentKit

Posted on

PostToolUse Hooks for Audit Logs: A Production Pattern with Code

If your team is running Claude Code in production, you can probably tell me what it can do. The harder question is what it actually did last Tuesday at 3pm — which Bash calls ran, against which repo, with what exit code. The PostToolUse hook is the lifecycle event you have to use, and most documentation skips past it in two paragraphs.

The previous article in this series treated PreToolUse as a tiny state machine — a gate that decides what Claude Code is allowed to do next. PostToolUse is the other half of that pair. It runs after the tool call has already happened, and the only useful thing it can do is record. Everything we describe below is built around that one premise, because the moment you forget it, the hook stops being an audit log and becomes a liability.

The shape of a PostToolUse hook

A PreToolUse hook is a gate. It runs before the tool call, and its exit code decides whether the call goes through. A PostToolUse hook is a recorder. It runs after the call, sees the result, and writes a line somewhere durable. The two hooks share a payload shape but they answer different questions: "should this happen" versus "what just happened."

For compliance work, the second question is the one that matters. SOC 2 CC6.1 and CC7.2 controls, the kind teams typically point to under access logging and system monitoring, are about retroactive evidence — "show us what your privileged operators did over the last ninety days." Claude Code in agentic mode is, by any reasonable reading of those controls, a privileged operator running on your engineer's machine. Whatever your interpretation of the control text, the answer "we will check the model's session transcript" is not going to land well with an auditor.

The official Claude Code documentation describes PostToolUse in roughly two paragraphs and a JSON schema. That is enough to get a hook running. It is not enough to get one that survives a compliance review. The rest of this article fills that gap.

A hook that does the basics

Here is the core of a PostToolUse audit hook. The version below is the one we are putting into our own Claude Code Max setup, designed against the failure modes we have seen in adjacent hook code over the last few months.

#!/usr/bin/env bash
# PostToolUse: append every tool call as a JSON line.
set -uo pipefail

INPUT=$(cat)
[ -z "$INPUT" ] && exit 0

TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
TOOL=$(echo "$INPUT"      | jq -r '.tool_name // "unknown"')
CMD=$(echo "$INPUT"       | jq -r '.tool_input.command // empty')
EXIT_CODE=$(echo "$INPUT" | jq -r '.tool_response.exit_code // 0')

mkdir -p ~/.claude/audit
jq -nc \
  --arg ts "$TS" --arg tool "$TOOL" --arg command "$CMD" \
  --argjson exit "$EXIT_CODE" \
  '{ts: $ts, tool: $tool, command: $command, exit_code: $exit}' \
  >> ~/.claude/audit/$(date -u +%Y-%m-%d).jsonl

exit 0
Enter fullscreen mode Exit fullscreen mode

A few things in this snippet are not accidental. The timestamp is UTC ISO-8601, because by the time you are reading the log six weeks later you do not want to be guessing what timezone the engineer's laptop was in. The output format is JSON Lines (one event per line, no commas, no wrapping array), because it is append-friendly, grep-friendly, and trivially streamable into anything that wants line-delimited JSON. The file rotates daily, because monthly files become unwieldy past 50 MB and hourly files explode in count. The exit code is 0 regardless of whether the write succeeded — we will come back to why that matters.

Twelve lines of substance is enough to start. We have not yet seen a team that needed more than this in the first month; what they needed was the discipline to actually keep the log, route it somewhere durable, and read it before an auditor asked. The hook itself is the cheap part.

PII redaction is a hook responsibility

An audit log is a piece of information you have promised yourself you will keep. That is also exactly what makes it dangerous. Every secret your engineer accidentally types into a Bash call is now sitting in ~/.claude/audit/ waiting for the next backup, the next disk image, the next stolen laptop. Data minimization is one of the principles in GDPR Article 5; whatever your interpretation of it, redaction at the hook layer is the cheapest place to apply it.

The pattern that has held up for us is a two-pass sed replacement, applied to the command field before it reaches the JSON line:

CMD_SAFE=$(printf '%s' "$CMD_RAW" | sed -E \
  -e 's/(Bearer |Token |key=|password=|secret=)[A-Za-z0-9_\.\-]+/\1<REDACTED>/gi' \
  -e 's/(ghp_|gho_|ghs_|ghu_|ghr_)[A-Za-z0-9]{20,}/\1<REDACTED>/g' \
  -e 's/(sk-ant-|sk-)[A-Za-z0-9_\-]{20,}/\1<REDACTED>/g' \
  -e 's/(AKIA)[A-Z0-9]{16}/\1<REDACTED>/g')
Enter fullscreen mode Exit fullscreen mode

This catches Bearer-style headers, key= and password= query parameters, GitHub personal access tokens by prefix, OpenAI and Anthropic keys by prefix, and AWS access keys by their AKIA pattern. A line in the resulting log looks like this:

{"ts":"2026-04-30T13:42:01Z","tool":"Bash","command":"curl -H 'Authorization: Bearer <REDACTED>' https://api.github.com/user","exit_code":0}
Enter fullscreen mode Exit fullscreen mode

The honest part is that this is a deny-list. New token formats — a new prefix from a new vendor, a custom in-house signing scheme, anything you have not added a regex for — will pass through unredacted until somebody updates the pattern. Two failure modes have shown up in our own usage. The first is the unknown-prefix problem above, which only an allow-list approach truly fixes (record only the command name, drop the arguments entirely; lose searchability, gain safety). The second is multi-arg tokens with embedded spaces, where the boundary the regex is looking for never appears; we have not yet seen this in the wild on a standard Claude Code Bash session, but custom CLIs that take credentials as positional arguments make it plausible.

If your environment is one where "best effort" is not enough, the right move is not to write a smarter regex. The right move is to record less.

Where this log goes

Once the hook is producing JSON lines, the next decision is where they live. The four destinations production teams actually pick are local files, syslog, a managed logging service, and an append-only object store. Each one is fine for a different size and shape of team, and the difference is mostly about retention guarantees and what your auditor wants to see.

A local file at ~/.claude/audit/ is what the snippet above writes to. It is free, it is fast, and it survives precisely as long as the laptop survives. For a single developer experimenting with hooks, that is enough. For a team, it is the destination you ship from, not the destination you ship to.

Syslog (logger -t claude-code piped from the same hook) gets you onto OS-standard logging infrastructure, which already has retention rules, rotation, and probably a fleet log shipper attached to it. The retention guarantee is whatever the OS is configured for, which means it is whatever your platform team has decided, which means you should ask them before assuming. We have seen this destination work well for small engineering teams whose platform group already has a logging pipeline.

A managed logging service — Datadog, Honeycomb, CloudWatch, the rest — is where most enterprise teams end up. The hook becomes a curl into the service's intake, or the file gets tailed by the service's agent. Cost scales with volume. Retention is configurable. The compliance fit is generally good, because these services advertise their controls precisely so that compliance teams can read them.

An append-only object store with immutability guarantees — S3 with Object Lock, GCS with retention policies — is the destination teams pick when their auditor asks specifically about tamper-resistance. The hook does not write directly there; a daemon ships the rotated daily files in. This is where the audit log graduates from "we keep records" to "we can prove we kept records."

Destination Retention Immutability Cost Compliance fit
~/.claude/audit/ OS-dependent Weak $0 Dev / staging only
Syslog OS configuration Medium $0 Small team
Datadog / Honeycomb Configurable Medium Volume-based Enterprise
S3 + Object Lock Effectively infinite Strong Storage-based SOC 2 / SOX evidence

Reading the table is less interesting than the reason behind the picks. Teams move from row one to row four roughly in step with how seriously they are taking compliance, and roughly in step with how often the audit log gets read by someone who is not the engineer who wrote it.

What does not belong in this hook

This is the section where we want to say something specific about scope, because audit infrastructure has a well-known failure mode and there is no reason a Claude Code hook would be exempt. An audit hook starts as twelve lines that record an event. Then somebody adds an alert when a particular command runs. Then somebody adds a filter to drop noisy commands. Then somebody adds an aggregation step that batches calls before writing. Six months later, the hook is two hundred lines, takes longer to run than the actual tool call, and is the thing that breaks when the next Claude Code release changes a payload field.

Alerting logic, filtering by command type, realtime aggregation — none of these belong in the hook itself. The hook records the event. A separate process — a log tailer, a scheduled query, an alerting rule on the centralized log destination — reads the event and decides whether to do something about it. The separation matters because hooks degrade and logs accumulate, and the cost of mixing the two responsibilities shows up later, when the system is harder to fix.

The shape we want to defend is one where the hook is the cheapest piece of code in the audit pipeline, and any time it accumulates "just one more responsibility" it becomes the most expensive. Keep the hook focused: write the event, redact the obvious secrets, exit zero, get out of the session's way.

That last point — exit zero — is also why the snippet above sets set -uo pipefail instead of set -e, and why every internal failure path falls back to a stderr warning rather than a non-zero exit. PostToolUse runs after the tool has already executed. There is no version of "the hook failed, so the call should not have happened" that makes sense at this point in the lifecycle. A non-zero exit from a PostToolUse hook propagates as a hook failure into the session, which is the wrong response when the only thing the hook does is record. Fail open with a visible warning. Never fail closed in silence.

The two-week checklist

If you put this hook in place today, here is what we recommend you check before two weeks pass: are the timestamps you are actually getting in UTC, or did somebody's laptop quietly drift; is the file size growing at the rate you predicted, or is it ten times bigger because some background tool is calling Bash on a loop; have you tested the redaction against a real command that contained a real-shaped token, not just the placeholder you used to write the regex; can your compliance lead read one day's log without engineering help, or does the schema need a field renamed before it makes sense to anyone outside the team that built it. We have not yet seen a team that got all four right on the first try, and the order in which they fail is roughly the order we just listed them in.

Why this hook earns its keep

The PostToolUse hook is the cheapest piece of compliance infrastructure available to a team running Claude Code. The cost of writing it is a dozen lines of Bash, a regex you can copy, and a directory you can create with mkdir -p. The cost of not writing it is the inability to answer "what did the agent do" six weeks after the fact, which is precisely the question that gets asked when something has gone wrong and somebody needs an answer in writing.

Most documentation treats PostToolUse as a logging convenience. The teams we have seen scale Claude Code beyond a single developer treat it as the audit trail. The framing matters because it changes which destination you pick, how much energy you spend on redaction, and whether the hook ends up living in a notes/ folder or in a settings file that is reviewed every time somebody onboards.

Not legal advice. We describe own usage and patterns we have observed; the way these patterns map onto SOC 2, SOX, HIPAA, or GDPR in your specific environment is a question for your compliance team.


We are open-sourcing the AgentKit Hooks Pack — production-ready templates for PreToolUse permission gating, PostToolUse audit logs, kill switch sentinels, notification routing — under Apache 2.0 in late May. The day it lands, we email a launch note and the Companion Guide PDF (sixty pages on lifecycle events, failure modes, and rollout patterns) to the pre-launch list. To get on it: imta71770-dot.github.io/agentkit-hooks-pack. The repo with templates and license is at github.com/imta71770-dot/agentkit-hooks-pack.

Top comments (0)