Part of the AI coding agent security checklist

A Claude Code audit log from what is already on disk

Before you install a logging hook, look at what Claude Code already wrote down: every tool call from the last 30 days, by default.

Checked against Claude Code's documentation, the advisories and research under Sources, and ranwhat's source. Published .

01 / Record

The record you already have

Claude Code saves each session to a transcript as you work. Its documentation describes the file as the full conversation: every message, every tool call and every tool result. That is an audit log in all but name, and it is already on your disk.

Where ~/.claude/projects/<project>/<session-id>.jsonl, one JSON object per line. <project> is the working directory with every character that is not a letter or digit replaced by a hyphen, so /Users/you/app becomes -Users-you-app. With CLAUDE_CONFIG_DIR set, the whole tree lives under that directory instead.
What Anything that passes through a tool: each Bash command and its output, each file the Read tool opened and what it contained, each Edit, Write and MCP call. Large outputs are spilled to <session>/tool-results/, and subagents keep their own transcripts in <session>/subagents/.
How long cleanupPeriodDays, 30 by default. After a session starts, a background sweep deletes older transcripts without a message. A session you last used five weeks ago has no record unless you raised the setting before it aged out, or started or last continued it in Claude Desktop or Cowork: Claude Code v2.1.248 and later keep those at any age by default.
Prompts ~/.claude/history.jsonl holds every prompt you typed, with a timestamp and the project path, and the sweep leaves it alone. It records what you asked for, not what ran.
Missing A session started with CLAUDE_CODE_SKIP_PROMPT_HISTORY=1, or a claude -p run with --no-session-persistence, writes no transcript at all.
Trust Plaintext, not encrypted at rest, and protected only by file permissions. Anything running as your user can edit or delete it. Treat it as evidence for yourself, not proof for a third party.

To keep more than a month, raise the setting in ~/.claude/settings.json. The minimum is 1, and 0 fails validation rather than meaning forever. A higher value keeps what the sweep has not reached yet. It does not bring back what it already deleted.

~/.claude/settings.json
{
  "cleanupPeriodDays": 365
}

Look without installing anything

ls -lt ~/.claude/projects/*/*.jsonl | head      # newest sessions first
grep -lF -- 'git push --force' ~/.claude/projects/*/*.jsonl
claude --resume <session-id>                   # then /export to save it as text

The file name is the session ID. grep tells you which transcripts contain a string, not that the command ran: the same text turns up in a file the agent read, in a search pattern or in an echo. /export writes the conversation as plain text with tool output rendered, which reads better than raw JSON. Claude Code's documentation says the line format is internal and changes between versions, so any script that parses it can break on a release, ranwhat included.

02 / Read

Read it back with ranwhat watch

A month of transcripts is too long to read and mostly routine. ranwhat watch reads them where they are and reports the calls worth a second look. No hook, no proxy, and it sends nothing anywhere.

$ uvx ranwhat watch --days 30
Install

uvx runs it without installing it. --days 90 finds more if cleanupPeriodDays was already above 30, if a session you resumed within the retention period has older actions in it, or for sessions started or last continued in Claude Desktop or Cowork, which Claude Code v2.1.248 and later keep at any age by default.

Nine rules

RuleSeverityFor example
Credential accessCriticalcat ~/.aws/credentials, Read on .env
Secret-shaped string in a tool callCriticalsk_live_… ghp_… AKIA…
Package or release publishedCriticalnpm publish twine upload docker push
Cloud resource destroyed or modifiedCriticalterraform destroy kubectl delete aws iam attach-…
Financial API calledCriticalStripe charges, refunds, transfers or payouts
Log or history tamperingCriticalhistory -c aws cloudtrail stop-logging
Destructive gitHighgit push --force git reset --hard
Recursive deletionHighrm -rf ~/Documents/old-notes find . -delete
Local file sent to the networkHighcurl -d @dump.sql curl -F [email protected]

Severity follows the target, not the verb: rm -rf build is not reported, rm -rf ~/Documents is high and rm -rf "$HOME" is critical.

As a log you can keep

ranwhat watch --json prints one record per flagged call:

ranwhat watch --jsonone record
{
  "source": "claude-code",
  "session": "3f2a9c1e-0b7d-4e55-9a10-2c6e8d4b7f01",
  "project": "-Users-you-app",
  "timestamp": "2026-09-26T14:42:10.000Z",
  "tool_name": "Bash",
  "tool_call_id": "toolu_01",
  "payload_hash": "9006c7010e00d3a2",
  "severity": "high",
  "hits": [
    {
      "rule": "fs.destructive",
      "severity": "high",
      "title": "Bulk or recursive deletion",
      "why": "Recursive deletion. Recoverable only if something else was backing it up.",
      "evidence": "rm -rf ~/Documents/old-notes"
    }
  ]
}
ranwhat watch --json | jq -r '.[] | [.timestamp, .severity, .tool_name, .hits[0].evidence] | @tsv'
Once each A call replayed by a resumed session is reported once, not once per transcript that carries it.
Not actions grep "rm -rf" src/ and other searches, echo and printf arguments, heredoc bodies, python -c and node -e payloads, comments, and git rm --cached. A bash -c payload is parsed, because it really is shell. A secret-shaped string is the exception: it is reported wherever it appeared verbatim in a tool call, including echo and printf arguments, heredoc bodies, -c or -e payloads and search patterns, because it is in the transcript either way.
Moved config When CLAUDE_CONFIG_DIR is set, ranwhat reads $CLAUDE_CONFIG_DIR/projects instead. --root PATH reads any other directory.
Nothing read The report's header counts the transcripts it read. A run that reads nothing says that nothing was checked, rather than that nothing was found. That almost always means the wrong directory or too short a window, not a clean history.
Not read yet The spilled outputs in <session>/tool-results/, and history.jsonl. Subagent transcripts in <session>/subagents/ are read, and each call in them is credited to the session that started the subagent.
Call, not result watch judges each call as the agent made it. It does not read the result, so a call you refused or one that failed is reported the same way as one that ran. The result is in the transcript beside it, and the result before a call often explains it. In 0DIN's demonstration of 25 June 2026, Claude Code ran python3 -m axiom init because an error message told it to, and that command opened a reverse shell. watch does not flag it. When a session looks wrong, resume it, /export it, and read the result before each command.
03 / Hook

Log every command from now on with a hook

The transcript keeps everything for a month. For one line per command, kept as long as you like, Claude Code's hooks guide has a ready example.

~/.claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.command' >> ~/.claude/command-log.txt"
          }
        ]
      }
    ]
  }
}

In ~/.claude/settings.json it covers every project on this machine. In a repository's .claude/settings.json it covers that project and can be committed. If the file already has a hooks key, merge into it. The command needs jq on your path, and /hooks lists what is registered.

The hook's input also carries session_id, cwd and hook_event_name. For one JSON line per command with a time on it, use this as the command instead:

jq -c '{time: (now | todate), event: .hook_event_name, session: .session_id, cwd: .cwd, command: .tool_input.command}' >> ~/.claude/command-log.jsonl
Failures PostToolUse fires only after a tool call succeeds. A command that exits non-zero fires PostToolUseFailure instead, so add the same entry under that event or the log misses every failed command. A call refused at the permission prompt fires neither; PreToolUse sees it.
Subagents Hooks fire inside subagents too, with an agent_id field in the input, so the log covers their commands as well.
transcript_path The input includes the session's transcript path. The transcript is written asynchronously and may not hold the current turn's latest messages when the hook fires, so log from the hook's own input rather than reading the file.
Off switch "disableAllHooks": true in any settings file other than managed settings turns off user, project, local and plugin hooks, and a project's settings take precedence over yours. Hooks in managed settings keep running; yours do not.
Same weakness The log is a plaintext file on the same machine. It is not on the retention sweep's list, so it grows until you rotate it, and anything running as you can edit it.
04 / OpenTelemetry

Send it to your own collector

A hook writes to this machine. OpenTelemetry sends events to a collector you run, as they happen. Claude Code's monitoring documentation calls these events the audit data source for Claude Code activity.

export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_LOGS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_LOG_TOOL_DETAILS=1
claude
Events Each tool call that runs produces a claude_code.tool_result event with tool_name, success and duration_ms. A refused call produces a tool_decision event with decision set to reject. Both carry the tool_use_id that hooks receive, so the two records can be joined.
The gate Without OTEL_LOG_TOOL_DETAILS=1, neither the Bash command nor the tool input is logged. With it, tool_parameters carries the full command, and tool_input the arguments, with values over 512 characters truncated and the whole bounded to about 4K characters.
Output OTEL_LOG_TOOL_CONTENT=1 adds what the tool returned, such as file contents and command output, as a tool.output span event, truncated at 60 KB by default. It needs tracing: CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1 and OTEL_TRACES_EXPORTER.
Defaults Export is opt-in, and both OTEL_LOG_TOOL_DETAILS and OTEL_LOG_TOOL_CONTENT are off by default. The data goes only to the endpoint you configure, never to Anthropic. Commands can carry secrets, so filter at the collector if they will be stored.
Repositories A repository's .claude/settings.json cannot turn telemetry on or send it elsewhere, but it can set OTEL_LOGS_EXPORTER to none, unless that variable comes from managed settings, a --settings file or the shell you start claude from. Export it in the shell.
Nothing arrives Submit a prompt and look for claude_code.user_prompt. If it is not there, start with claude --debug-file <path> and look for [3P telemetry] errors. Claude Code emits the raw events only; alerting is the collector's job.
05 / Choose

Which one to use

RecordCoversLivesWatch for
Transcript Already there: the last 30 days, or cleanupPeriodDays Local plaintext, editable by anything running as you Silent deletion after the period; a format that changes between versions
Hook From the moment you add it, for as long as you keep the file A local file you define Failed commands unless you add PostToolUseFailure; disableAllHooks in a project
OpenTelemetry From the moment you enable it, for as long as your backend keeps it Off the machine, at your collector Only what the gates allow: no commands without OTEL_LOG_TOOL_DETAILS=1

Most people want the first today and one of the other two going forward. Run uvx ranwhat watch --days 30 over what is on disk now. Then add the hook if the record can stay on this machine, or OpenTelemetry if it has to leave it, where a later edit here cannot reach it.

A policy is not a record

Settings say what should happen. The transcript says what did. Before 2.1.260, a Claude Code session signed in to a Team or Enterprise account fetched the organisation's server-managed settings with an API key it had stored earlier, from a past /login or written into its config. When the settings endpoint rejected that key, the session still ran as the organisation's account, but with none of its server-managed policy (no deny rules, model restrictions or managed-only locks), or with a stale cached copy (CVE-2026-103012, published 29 September 2026). Enterprise was affected from 2.0.68 and Team from 2.1.38. MDM and file-based managed settings were not.

claude --version               # 2.1.260 or later
/permissions                   # the organisation's rules should be listed
uvx ranwhat watch --days 30    # what ran while they may not have applied

watch reads only what retention kept, and a session nobody used within cleanupPeriodDays is already gone. Administrators can set requiredMinimumVersion to 2.1.260 through MDM or a managed settings file.

Nor does the transcript record a change to the configuration itself. Mitiga showed an npm postinstall script marking common clone paths as trusted in ~/.claude.json, after which a hook in a repository cloned there re-pointed an OAuth MCP server at a proxy on localhost at every start, and read each token that passed. Anthropic ruled it out of scope, Mitiga reports, because the chain depends on consent the user has already given. Mitiga's advice is to watch ~/.claude.json and a project's MCP files for changes, and to keep a list of the MCP endpoints you approved. Hooks deserve the same watch: /hooks lists what is registered.

06 / Next

When the log shows a deletion

An rm -rf in the record tells you what ran. Whether the files can come back is a separate question, and checkpoints will not answer it: Claude Code's checkpointing does not track files changed by Bash commands. Start with the guide to files Claude Code deleted. For everything else Claude Code keeps on disk, read the Claude Code history guide; for the controls around all of this, the AI coding agent security checklist. ranwhat watch has the full rule set, and the rest of the guides are listed here.