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
| Path | Notes |
|---|---|
~/Library/Application Support/Claude/local-agent-mode-sessions/<acct>/<org>/local_<uuid>/audit.jsonl | The audit log, one per session |
.../local_<uuid>/.audit-key | The session's HMAC key, safeStorage-encrypted |
.../agent/local_ditto_<uuid>/audit.jsonl | Dispatch 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:
| Field | Meaning |
|---|---|
_audit_timestamp | ISO 8601 UTC time the line was written (covered by the HMAC) |
_audit_redactions | Fields the app redacted before writing (covered by the HMAC) |
_audit_hmac | 64 lowercase hex characters; always the last key on the line |
The remaining keys are the message itself.
Event Types
type | subtype examples | Meaning |
|---|---|---|
system | init, status | Session start: working directory, permission mode, tools, MCP servers and their status, plugins, Claude Code version |
assistant | Assistant turn: text, thinking, tool_use blocks with inputs; model and request ID | |
user | User turn or tool results (tool_result blocks with an error flag) | |
system | permission_request, permission_response, permission_auto_approved | Permission flow: the tool and input, the decision, and whether it was granted |
result | success | End of a run: duration, turn count, total cost in USD, permission denials, error flag |
rate_limit_event | Rate 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_requestrecords the tool and its proposed input.permission_responserecords the decision (allowordeny) and whether it was granted.permission_auto_approvedrecords a call approved without prompting, for example because of a standing permission or a bypass setting. Compare against thepermission_modein theinitevent and thebypass_permissions_gate_by_accountsetting in MCP and Extensions.
Together with tool_use inputs these show which commands ran and who authorised them.
Flags
| Flag | Meaning |
|---|---|
is_synthetic | Message generated by the system rather than the model |
is_replay | Message 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.stringifydiffer from other languages, so a re-serialized body will not match. prevstarts as the literalaudit-chain-genesis, then becomes the previous line's lowercase hex HMAC (the claimed value, as the app does)._audit_timestampand_audit_redactionsare 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:
| Property | Value |
|---|---|
| Prefix | v10 |
| Cipher | AES-128-CBC with PKCS#7 padding |
| Key derivation | PBKDF2-HMAC-SHA1 over the Keychain secret, salt saltysalt, 1003 iterations, 16-byte output |
| IV | 16 space characters (0x20) |
| Secret | The password in the login Keychain item Claude Safe Storage |
| Size and mode | 51 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.
| Status | Meaning |
|---|---|
verified | Every signed line matched, and no unsigned line follows the first signed line |
verified_partial | Every signed line matched, but unsigned lines follow the first signed line |
broken | At 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_failed | The supplied password did not decrypt .audit-key (wrong password or corrupt file) |
key_mismatch | A supplied raw key matched no line in a log with at least two signed lines; it most likely belongs to another session |
no_key | The session has no .audit-key |
unverifiable | Nothing to verify (no signed lines) or the key file has an unrecognised format |
skipped | Verification 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
| Field | Meaning |
|---|---|
lines, signed_lines, unsigned_lines | Counts (empty lines excluded) |
first_bad_line | First failing line (1-based physical line number) |
last_signed_line, chain_head | Last signed line and its HMAC: the chain head |
malformed_hmac_lines | Lines whose HMAC suffix is present but malformed |
key_source, key_file_format | How the key was obtained; format of the file |
algorithm_note | The 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_encryptedresult 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_usefind the matchingtool_resultby ID, and the permission event that preceded it. - Cost and scale.
resultevents givetotal_cost_usd,num_turns, andduration_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_headagainst 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_redactionsshows 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.