AI Assistants

Claude Desktop Cowork Audit Log

Overview

Each Cowork session can keep an audit.jsonl file: one JSON object per line recording agent activity, including assistant and user turns, tool calls with their inputs, tool results, permission requests and decisions, and the final result with cost and duration. Every line is signed with an HMAC that chains it to the line before, so an edit or a deleted line in the middle of the file is detectable by anyone who holds the key.

That makes it the most useful single file for answering "what did the agent do, and was the user asked?", and one of the few records in a consumer application designed to be tamper-evident. It also has limits, described under Limitations.

Known versus inferred

The HMAC framing below was confirmed against the application's own code (build 2.42304.0), and a verifier built from it validates fixtures and detects modification. The key's encryption format is Electron's documented safeStorage scheme. What is not established is how the framing behaves on much older or future builds. Report a mismatch on an unfamiliar build as unverifiable, not as tampering, until the framing is re-confirmed for that version.

File Locations

PathNotes
~/Library/Application Support/Claude/local-agent-mode-sessions/<acct>/<org>/local_<uuid>/audit.jsonlThe audit log, one per session
.../local_<uuid>/.audit-keyThe session's HMAC key, safeStorage-encrypted
.../agent/local_ditto_<uuid>/audit.jsonlDispatch sessions use the same layout

Each session has its own log and its own key, and a chain does not cross sessions.

Record Format

Each line is the output of JSON.stringify on an Agent SDK stream message plus three audit fields:

FieldMeaning
_audit_timestampISO 8601 UTC time the line was written (covered by the HMAC)
_audit_redactionsFields the app redacted before writing (covered by the HMAC)
_audit_hmac64 lowercase hex characters; always the last key on the line

The remaining keys are the message itself.

Event Types

typesubtype examplesMeaning
systeminit, statusSession start: working directory, permission mode, tools, MCP servers and their status, plugins, Claude Code version
assistantAssistant turn: text, thinking, tool_use blocks with inputs; model and request ID
userUser turn or tool results (tool_result blocks with an error flag)
systempermission_request, permission_response, permission_auto_approvedPermission flow: the tool and input, the decision, and whether it was granted
resultsuccessEnd of a run: duration, turn count, total cost in USD, permission denials, error flag
rate_limit_eventRate limiting

The subtype values listed are those observed. Preserve unknown fields. Treat the set as version-dependent.

Permission Events

Permission events separate user decisions from agent actions:

  • permission_request records the tool and its proposed input.
  • permission_response records the decision (allow or deny) and whether it was granted.
  • permission_auto_approved records a call approved without prompting, for example because of a standing permission or a bypass setting. Compare against the permission_mode in the init event and the bypass_permissions_gate_by_account setting in MCP and Extensions.

Together with tool_use inputs these show which commands ran and who authorised them.

Flags

FlagMeaning
is_syntheticMessage generated by the system rather than the model
is_replayMessage replayed from earlier history on resume; do not count it as new activity

The HMAC Chain

Framing

prev := "audit-chain-genesis"            # per audit.jsonl; resumes across app restarts
for each non-empty line L:
    m := match  ,"_audit_hmac":"([0-9a-f]{64})"}   at the end of L
    if no match:  line is unsigned; do not change prev
    body := L with that suffix removed, plus "}"
    expected := hex( HMAC-SHA256( key32, prev || body ) )
    if expected != m.group1:  chain breaks at this line
    prev := m.group1                      # chain on the claimed value

Points that matter when you reimplement this:

  • The message is the raw bytes of the line with the HMAC suffix removed and the closing brace restored. Do not parse and re-serialize the JSON. Key order, string escaping, and number formatting in JavaScript's JSON.stringify differ from other languages, so a re-serialized body will not match.
  • prev starts as the literal audit-chain-genesis, then becomes the previous line's lowercase hex HMAC (the claimed value, as the app does).
  • _audit_timestamp and _audit_redactions are part of the signed body.
  • The chain resumes across restarts from the last signed line of the existing file.
  • The key is 32 raw bytes: the base64 decoding of the plaintext inside .audit-key.
  • A single trailing carriage return can be ignored so that a CRLF-converted copy still checks.

Because the chain advances on the claimed value, a modified line fails by itself and the next line still verifies against the claimed HMAC. A deleted line makes the line after it fail.

The Key File

.audit-key is encrypted with Electron safeStorage, not stored as plain base64. On macOS:

PropertyValue
Prefixv10
CipherAES-128-CBC with PKCS#7 padding
Key derivationPBKDF2-HMAC-SHA1 over the Keychain secret, salt saltysalt, 1003 iterations, 16-byte output
IV16 space characters (0x20)
SecretThe password in the login Keychain item Claude Safe Storage
Size and mode51 bytes (3-byte prefix plus 48 bytes of ciphertext), mode 0600 on the reference host

Decrypting yields a base64 string of the 32-byte HMAC key. Without the Safe Storage password the key cannot be recovered from the session folder alone, so the chain cannot be verified from the audit file and key file by themselves.

This is why macfor never reads the Keychain: it records the encrypted .audit-key as evidence and takes the password from the examiner. Older builds may have stored a plaintext base64 key. If the file is a plain base64 key, no password is needed.

Verification Statuses

macfor emits one claude_cowork_audit_verification record per audit file.

StatusMeaning
verifiedEvery signed line matched, and no unsigned line follows the first signed line
verified_partialEvery signed line matched, but unsigned lines follow the first signed line
brokenAt least one signed line failed; first_bad_line gives the first
key_encrypted.audit-key is safeStorage-encrypted and no audit_key was supplied. Nothing was checked, and this is not a tamper finding
key_decrypt_failedThe supplied password did not decrypt .audit-key (wrong password or corrupt file)
key_mismatchA supplied raw key matched no line in a log with at least two signed lines; it most likely belongs to another session
no_keyThe session has no .audit-key
unverifiableNothing to verify (no signed lines) or the key file has an unrecognised format
skippedVerification was turned off

Meaning of "unsigned"

A line that does not end in a valid _audit_hmac suffix is unsigned. The app writes unsigned lines when no key was available at the time, so unsigned lines at the start of a log can be benign. It is reported separately and is not by itself evidence of tampering. The verification record counts them (unsigned_lines, unsigned_line_numbers).

Unsigned lines after the first signed line are different. An editor who changes a line and strips its HMAC produces exactly that pattern. They are counted as unsigned_after_signed, and they downgrade a matching chain to verified_partial. Examine them against the transcript and other sources.

Other Verification Fields

FieldMeaning
lines, signed_lines, unsigned_linesCounts (empty lines excluded)
first_bad_lineFirst failing line (1-based physical line number)
last_signed_line, chain_headLast signed line and its HMAC: the chain head
malformed_hmac_linesLines whose HMAC suffix is present but malformed
key_source, key_file_formatHow the key was obtained; format of the file
algorithm_noteThe framing used

Record the chain_head in your notes. A later copy of the same log with a different head, or a head that does not appear in the log, shows what changed.

Verifying With the Safe Storage Password

With macfor

Provide the password through an environment variable so it does not appear in the process list or the logs:

export CLAUDE_SAFE_STORAGE_PW='...'
macfor collect --plugin ai.claudedesktop \
  --plugin-opt ai.claudedesktop.audit_key=env:CLAUDE_SAFE_STORAGE_PW \
  --output ./evidence.zip

audit_key accepts either the Safe Storage password or an already-decrypted base64 key. The value is never logged or written to the output. Without it, every encrypted chain reports key_encrypted.

Obtaining the password

The password lives in the user's login Keychain. Retrieving it prompts for the user's authorisation and may need their login password. Only retrieve it where you have the authority to do so. If you have only a disk image and not the login password, you cannot derive the key, and the chains stay unverified.

By hand

Decrypt the key and check the chain with a few lines of Python. This is a reference, not a validated tool. Test it against a known-good session first.

import base64, hashlib, hmac, re, sys
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding

password = sys.argv[1].encode()
blob = open(".audit-key", "rb").read()
assert blob[:3] == b"v10"
aes_key = hashlib.pbkdf2_hmac("sha1", password, b"saltysalt", 1003, 16)
dec = Cipher(algorithms.AES(aes_key), modes.CBC(b" " * 16)).decryptor()
padded = dec.update(blob[3:]) + dec.finalize()
unpad = padding.PKCS7(128).unpadder()
key32 = base64.b64decode(unpad.update(padded) + unpad.finalize())
assert len(key32) == 32

prev = b"audit-chain-genesis"
pat = re.compile(rb',"_audit_hmac":"([0-9a-f]{64})"\}$')
for n, line in enumerate(open("audit.jsonl", "rb").read().split(b"\n"), 1):
    line = line.rstrip(b"\r")
    if not line:
        continue
    m = pat.search(line)
    if not m:
        print(n, "unsigned")
        continue
    body = line[:m.start()] + b"}"
    calc = hmac.new(key32, prev + body, hashlib.sha256).hexdigest().encode()
    if calc != m.group(1):
        print("first broken line:", n)
        break
    prev = m.group(1)
else:
    print("chain intact; head:", prev.decode())

Timestamps

_audit_timestamp is an ISO 8601 UTC string. macfor emits it as timestamp, keeping the original in timestamp_raw. Within a log the timestamps are covered by the HMAC, so altering one breaks the chain. Cross-check them against result durations and against Logs.

Investigation Notes

  • Start with the verification record. A key_encrypted result means unverified, not unaltered.
  • Reconstruct from init. The first event shows the working directory, permission mode, tools, and MCP servers available at the start of the run.
  • Follow tool calls. For each tool_use find the matching tool_result by ID, and the permission event that preceded it.
  • Cost and scale. result events give total_cost_usd, num_turns, and duration_ms. These help estimate how much work a session did.
  • Corroborate. Compare against the embedded Claude Code transcript and the files in outputs/ (see Cowork Sessions).

Limitations

  • Truncation is undetectable from the file alone. Removing lines from the end leaves a valid chain. Compare chain_head against another copy, such as a backup or a prior collection.
  • Whoever holds the key can forge a log. The key sits on the same machine, protected by the user's Keychain. The HMAC proves integrity to someone with the key. It does not prove the log was never rewritten by someone who also had it.
  • Stripping the HMAC from edited lines is not caught by the chain, only flagged by unsigned_after_signed.
  • Redactions. _audit_redactions shows the app removed some fields before writing, so some content is absent by design.
  • A log may not exist. Older builds and non-Cowork sessions do not write one.

What macfor Collects

The ai.claudedesktop plugin emits claude_cowork_audit_event and claude_cowork_audit_verification records. audit.jsonl and the encrypted .audit-key are copied byte-for-byte as original evidence, and a plaintext key is withheld unless include_secrets is set. Under skip_content the log is hashed but not copied. Verification is on by default (verify_audit).

{
  "type": "claude_cowork_audit_verification",
  "session_id": "local_00000000-0000-4000-8000-000000000002",
  "lines": 118,
  "signed_lines": 118,
  "unsigned_lines": 0,
  "status": "key_encrypted",
  "key_file_format": "safestorage_v10"
}

All values above are illustrative.

References