How to Audit Claude Code Sessions: A Tamper-Evident Trail for Every Tool Call
Claude Code reads your files, writes code, and runs shell commands on your behalf. By default, the record of all that lives in a local transcript file on the same machine as the agent — editable, deletable, and unverifiable. This guide shows how to audit Claude Code sessions into a hash-chained trail you control, using two hooks and about a hundred lines of Python.
We run this on our own machine. Every Claude Code session across every project on it writes to a local ledger, and the numbers further down are from that ledger, not from a demo.
Why Claude Code sessions need an audit trail
A session you watch in the terminal is self-explanatory. A session that ran unattended — in a scheduled task, a CI job, an agentic loop, or just three days ago — is not. Sooner or later something goes wrong and you need to answer a narrow question: which files did it touch, which commands did it run, and in what order?
Claude Code does keep a local JSONL transcript per session, and for day-to-day debugging that is often enough. It stops being enough the moment the record itself is what's in question. The transcript sits on the same machine as the agent, under the same credentials, with no integrity proof: anything that could tamper with your repo could tamper with the file that says what happened to your repo. Logs are claims. An audit trail is evidence.
The fix is not more logging. It is moving the record off the agent's own trust boundary and making alteration detectable. That is the difference between observability and auditability, covered in more depth in the companion piece on how to audit AI agents.
How Claude Code hooks work
Claude Code ships a hook system: scripts that run automatically when specific session events fire. Two of them are all you need to audit a session.
PostToolUse— fires after every tool call. The event JSON arrives on stdin withsession_id,tool_name,tool_input,tool_response,cwd, andhook_event_name.SessionEnd— fires when the session closes, withreasonandtranscript_path. Good place to record a session-level summary.
The event name is SessionEnd, not PostSessionEnd; the matcher "*" on PostToolUse means every tool, not a subset. Both hooks below are registered as async with a short timeout, so the tool call never waits on the network.
// ~/.claude/settings.json
{
"hooks": {
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{ "type": "command",
"command": "python /path/to/spanchain_trace.py",
"timeout": 10, "async": true }
]
}
],
"SessionEnd": [
{
"hooks": [
{ "type": "command",
"command": "python /path/to/spanchain_trace.py",
"timeout": 15, "async": true }
]
}
]
}
}
One script, two events. It branches on hook_event_name.
Redaction first: secrets must not leave the machine
Start here, not at the end. A PostToolUse event for a Bash call contains the command line verbatim — and command lines are where credentials live: GF_API_KEY=… curl -H "Authorization: Bearer …". If you ship the event as-is, you have built a credential exfiltration pipeline with an audit trail attached.
Key-name filtering is not enough. In the event JSON the secret is inside a string value under an innocent key like command, so a filter that masks fields named password or token sees nothing to mask. The redaction has to run over the text itself, before the payload is assembled — and truncation has to come after redaction, or you leave half a secret in place.
#!/usr/bin/env python3
"""Claude Code -> Span Chain trace hook. PostToolUse + SessionEnd, async.
Reads the hook JSON from stdin, builds one OTLP/HTTP JSON span, POSTs it.
Tracing must never fail the session: every error path exits 0.
Disable with GF_CC_TRACE=0. Self-test: python spanchain_trace.py --self-test
"""
import json, os, re, secrets, sys, time, urllib.request
from datetime import datetime, timezone
from hashlib import md5
ENDPOINT = os.environ.get("GF_CC_TRACE_ENDPOINT", "http://localhost:4000")
API_KEY = os.environ.get("GF_API_KEY", "")
MAX_ATTR_LEN = 400
_NAME = r"[A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD|CREDENTIAL)[A-Za-z0-9_]*"
REDACT_PATTERNS = [
# NAME=value / NAME: value / $env:NAME = value (quoted or bare)
(re.compile(rf"(?i)((?:\$env:)?{_NAME}\s*[=:]\s*)(\"[^\"]*\"|'[^']*'|\S+)"), r"\1***"),
# Anthropic / OpenAI style API keys anywhere in the text
(re.compile(r"sk-[A-Za-z0-9_\-]{8,}"), "sk-***"),
# Authorization: Bearer <token>
(re.compile(r"(?i)(bearer\s+)\S+"), r"\1***"),
# URL credentials: scheme://user:pass@host
(re.compile(r"(\w+://[^/\s:@]+:)[^@\s]+(@)"), r"\1***\2"),
]
def redact(text: str) -> str:
for pattern, repl in REDACT_PATTERNS:
text = pattern.sub(repl, text)
return text
def clean(value: object) -> str:
"""Redact THEN truncate — truncation must never expose a half-masked secret."""
return redact(str(value))[:MAX_ATTR_LEN]
Two properties make this worth trusting. It is conservative about what it sends: every attribute goes through clean(), capped at 400 characters, so no whole file contents and no whole command outputs end up in the ledger. And it is testable — the script carries a --self-test flag that asserts known secret shapes never survive into an assembled payload, and that a benign command passes through untouched:
$ python spanchain_trace.py --self-test
PASS 'GF_API_KEY="super-secret-123"' -> 'GF_API_KEY=***'
PASS "$env:ANTHROPIC_API_KEY = 'sk-ant-abc123def456'" -> '$env:ANTHROPIC_API_KEY = ***'
PASS 'export SECRET_KEY_BASE=abcdef0123456789' -> 'export SECRET_KEY_BASE=***'
PASS 'Authorization: Bearer eyJhbGciOi.payload.sig' -> 'Authorization: Bearer ***'
PASS 'ecto://gf_user:hunter2@localhost/span_chain_dev' -> 'ecto://gf_user:***@localhost/span_chain_dev'
PASS 'POSTGRES_PASSWORD: change_me_strong' -> 'POSTGRES_PASSWORD: ***'
PASS 'sk-proj-AAAABBBBCCCCDDDD blah' -> 'sk-*** blah'
PASS benign command untouched
PASS assembled OTLP payload contains no secret
OK (9 checks)
Run this before wiring the hook up. A redaction layer you have not tested is a rumour.
Building the span
Span Chain accepts OTLP/HTTP JSON on /v1/traces, so the payload is standard OpenTelemetry — no vendor SDK, just urllib. Two resource attributes carry the identity of the run:
service.instance.id→ the run id. One session becomes one run:cc-<UTC date>-<first 8 chars of session_id>.gf.eval_id→ an optional grouping key. Here it buckets sessions by month (cc-sessions-2026-09), which is what lets you compare runs later.
def _attr_value(v: object) -> dict:
if isinstance(v, bool):
return {"boolValue": v}
if isinstance(v, int):
return {"intValue": v}
return {"stringValue": clean(v)}
def build_payload(run_id, session_id, name, attrs, eval_id):
now_ns = str(time.time_ns())
return {"resourceSpans": [{
"resource": {"attributes": [
{"key": "service.instance.id", "value": {"stringValue": run_id}},
{"key": "gf.eval_id", "value": {"stringValue": eval_id}},
]},
"scopeSpans": [{"spans": [{
"traceId": md5(session_id.encode()).hexdigest(), # stable per session
"spanId": secrets.token_hex(8),
"name": name,
"startTimeUnixNano": now_ns,
"endTimeUnixNano": now_ns,
"attributes": [{"key": k, "value": _attr_value(v)} for k, v in attrs.items()],
}]}],
}]}
def send(payload: dict) -> bool:
req = urllib.request.Request(
f"{ENDPOINT}/v1/traces",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json",
"Authorization": f"Bearer {API_KEY}"},
)
try:
with urllib.request.urlopen(req, timeout=3) as res:
return 200 <= res.status < 300
except Exception:
return False # backend down -> silent drop, by design
On the run id. It is derived from the session id and today's UTC date, so it is stable for the whole session without any state on disk. A session that crosses UTC midnight splits into two runs — an acceptable trade for statelessness.
We first tried deriving the date from the transcript file's creation time. On Windows that produced a phantom run four months in the past. Creation time is not a clock.
The two event branches
PostToolUse records what the agent did: the tool name, and a short redacted summary picked from whichever field carries the intent — the command for Bash, the path for Read or Edit, the pattern for Grep. SessionEnd walks the transcript and records the session total: assistant turns and token usage, under the standard gen_ai.usage.* attribute names.
def tool_summary(tool_input: object) -> str:
if not isinstance(tool_input, dict):
return ""
for key in ("command", "file_path", "pattern", "prompt", "query"):
if tool_input.get(key):
return str(tool_input[key])
return ", ".join(sorted(tool_input))
def session_end(data: dict) -> dict:
input_tok = output_tok = turns = 0
try:
with open(data["transcript_path"], encoding="utf-8") as f:
for line in f:
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if entry.get("type") != "assistant":
continue
usage = (entry.get("message") or {}).get("usage") or {}
turns += 1
input_tok += usage.get("input_tokens", 0) or 0
output_tok += usage.get("output_tokens", 0) or 0
except (OSError, KeyError, TypeError):
pass
return {"gf.cc.event": "session_summary",
"gf.cc.reason": data.get("reason", ""),
"gf.cc.assistant_turns": turns,
"gen_ai.usage.input_tokens": input_tok,
"gen_ai.usage.output_tokens": output_tok}
def main() -> int:
if os.environ.get("GF_CC_TRACE") == "0":
return 0
data = json.load(sys.stdin)
event = data.get("hook_event_name", "")
session_id = data.get("session_id", "unknown")
run_id = f"cc-{datetime.now(timezone.utc):%Y%m%d}-{session_id[:8]}"
eval_id = f"cc-sessions-{datetime.now(timezone.utc):%Y-%m}"
if event == "PostToolUse":
attrs = {"gf.cc.event": "tool_call",
"gf.cc.tool": data.get("tool_name", "unknown"),
"gf.cc.summary": tool_summary(data.get("tool_input"))}
name = attrs["gf.cc.tool"]
elif event == "SessionEnd":
attrs = session_end(data)
name = "session_summary"
else:
return 0
# Wired globally, spans arrive from every repo on the machine. Without this
# attribute they all collapse into one undistinguishable bucket.
attrs["gf.cc.project"] = os.path.basename(data.get("cwd") or "") or "unknown"
send(build_payload(run_id, session_id, name, attrs, eval_id))
return 0 # never fail the tool call
def self_test() -> int:
cases = [('GF_API_KEY="super-secret-123"', "super-secret"),
("$env:ANTHROPIC_API_KEY = 'sk-ant-abc123def456'", "sk-ant"),
("export SECRET_KEY_BASE=abcdef0123456789", "abcdef"),
("Authorization: Bearer eyJhbGciOi.payload.sig", "eyJ"),
("ecto://gf_user:hunter2@localhost/span_chain_dev", "hunter2"),
("POSTGRES_PASSWORD: change_me_strong", "change_me"),
("sk-proj-AAAABBBBCCCCDDDD blah", "AAAA")]
failed = 0
for raw, leak in cases:
out = redact(raw)
ok = leak not in out
print(f" {'PASS' if ok else 'FAIL'} {raw!r} -> {out!r}")
failed += 0 if ok else 1
benign = "pytest tests/test_hook.py && git commit -m 'fix'"
assert redact(benign) == benign, "benign command must pass through untouched"
print(" PASS benign command untouched")
blob = json.dumps(build_payload("cc-test-0", "session-x", "Bash", {
"gf.cc.summary": 'GF_API_KEY="oops" curl -H "Authorization: Bearer tok123" http://x',
}, "cc-sessions-test"))
assert "oops" not in blob and "tok123" not in blob, "payload leaked a secret"
print(" PASS assembled OTLP payload contains no secret")
print(f"\n{'FAILED' if failed else 'OK'} ({len(cases) + 2} checks)")
return 1 if failed else 0
if __name__ == "__main__":
if "--self-test" in sys.argv:
sys.exit(self_test())
try:
sys.exit(main())
except Exception:
sys.exit(0) # tracing must never fail the session
Note the gf.cc.project attribute. Once the hook is registered at user level in ~/.claude/settings.json, it fires for every project on the machine — without a discriminator, spans from ten repos land in one undifferentiated pile.
Start the backend
Span Chain is MIT-licensed and self-hosted; one Compose command brings up Postgres, the app, and a Caddy gateway that terminates TLS.
git clone https://github.com/ghostfactory-art/spanchain.git
cd spanchain && cp .env.example .env
# set POSTGRES_PASSWORD, GF_API_KEY, SECRET_KEY_BASE (and DOMAIN for real TLS)
docker compose up --build
For a deployment with a real domain, point the hook at it and TLS just works:
export GF_CC_TRACE_ENDPOINT="https://spanchain.example.com"
export GF_API_KEY="your-gf-api-key"
Local trial, one gotcha. With DOMAIN=localhost the gateway issues a certificate from a local CA and redirects plain HTTP, which most HTTP clients — including Python's urllib — will refuse or silently drop. For a local-only trial, publish the ingest listener directly instead of fighting the certificate:
ports: ["127.0.0.1:4000:4000"] on the app service, then GF_CC_TRACE_ENDPOINT="http://localhost:4000". Loopback only; the gateway stays the path for anything reachable from outside.
Then use Claude Code as usual. Every tool call fires the hook; each span is hash-chained to the previous one in the same run.
What a captured session looks like
Numbers from our own ledger, which has been recording since 2026-06-06: 13 407 tool spans across 196 Claude Code sessions, about 68 spans per session, alongside spans from SDK-instrumented runs and load tests for a total of 25 033 entries over 1 277 runs.
The three most frequent span names are exactly what you'd expect an agent to spend its day on — Bash (7 457), Read (1 811), Edit (1 203). Note the capitalisation: the span name is the literal Claude Code tool name, so a query for what the agent ran is a query on Bash, not bash or run_command.
Each span carries the run id, a trace id stable for the whole session, the tool name, the redacted summary, the originating project, the capture timestamp, and a SHA-256 hash covering the previous hash plus the run and epoch identifiers. That last part is what makes the record evidence rather than a claim: a single modified byte anywhere in a run breaks verification for everything after it.
Verify the chain
Verification is a plain read-only request. No LLM calls, no external service, nothing to trust but the arithmetic:
curl -H "Authorization: Bearer $GF_API_KEY" \
http://localhost:4000/api/runs/cc-20260918-7d05058e/verify
{"error":null,"run_id":"cc-20260918-7d05058e","span_count":50,"verified":true}
The real response for the session that produced this article — 50 tool calls, chain intact.
A broken chain returns "verified": false with "error": "chain_broken". Verification is per run, so checking an entire ledger means iterating the runs — on ours, all 1 277 runs verified clean, 25 044 entries re-hashed in 4.7 seconds. That is the property that matters: re-verifying three months of agent history is cheap enough to do on a schedule.
There is a second endpoint for the artifact case — POST /api/verify with a sha256, which finds the span whose payload carries that hash and proves the chain from genesis to it. Useful when the question is "was this exact file produced by a recorded run", not "is this run intact".
What this record proves — and what it does not
Being precise about the boundary is the whole point of an audit trail, so:
It proves that a specific sequence of tool calls was recorded, in order, at recorded times, from a named project — and that nothing in the record has been altered since capture, because altering any entry invalidates every hash after it. Deleting the trail is still possible; changing it undetectably is not.
It does not prove that the agent's actions were correct, appropriate, or authorised. And it contains nothing the hook did not send: with the script above, that means tool inputs in summary form, not tool outputs, and not exit statuses. Whether the Bash call succeeded is not in this record — capturing outcome and latency is a separate piece of work, because doing it properly means running the same redaction over tool responses before they are ever assembled into a payload.
We would rather the trail under-claim and hold than over-claim and leak. An audit trail that quietly exports your API keys has failed at being an audit trail.
Where to take it next
- Self-host Span Chain — MIT, Docker Compose, Python and TypeScript SDKs, OTLP-compatible with LangChain, CrewAI, LlamaIndex, AutoGen and Pydantic AI.
- Read the background on auditing AI agents — claims versus evidence, what to log, and the compliance anchors (EU AI Act Article 12, SOC 2, HIPAA).
- Group sessions by
gf.eval_idand diff two runs of the same task against each other.
FAQ: Auditing Claude Code Sessions
How do I audit Claude Code sessions?
Register a PostToolUse hook and a SessionEnd hook in ~/.claude/settings.json. Both receive the session event as JSON on stdin. A small script turns each event into an OTLP span and posts it to an audit backend you control, where every span is hash-chained to the one before it — so any later edit to the record is detectable.
Where are Claude Code session logs stored by default?
Claude Code keeps a local JSONL transcript per session on the machine that ran it, and the SessionEnd hook receives its path as transcript_path. Those files are useful, but mutable: they live on the same machine as the agent, can be edited or deleted, and carry no integrity proof. Auditing means copying the events into an append-only, hash-chained store as they happen.
Does auditing slow Claude Code down?
No, if the hook is registered as async and fails silently. The script gets a short timeout, never writes to stdout, and exits 0 on every error path — including an unreachable backend. Observability must never block or fail the agent's tool call. The trade is explicit: when the backend is down, those spans are gone rather than delaying the session.
What does a hash-chained Claude Code audit trail actually prove?
That a given set of tool calls was recorded in a given order at a given time, and that the record has not been altered since: each entry's hash covers the previous hash plus the run and epoch identifiers, so a single changed byte breaks verification. It does not prove the agent's actions were correct, and it contains nothing the hook did not send.