Audit log and verification
One JSONL file per agent per UTC day. With identity.did set, each entry is Ed25519-signed and chained to the one before, and constle audit verify names the line and the kind of change when an entry was changed, inserted, reordered or deleted with another after it.
On this page
Tip
Step by step
Verify an AI agent's audit log reads the events that matter and verifies a log pinned to the identity you expect.
What gets loggedLink to this section
Each run writes to ~/.constle/logs/<agent>-<YYYY-MM-DD>.jsonl, named after the Agentfile's identity.name (the day is UTC). The log records enforcement and activity events, among them:
| Events | Written when |
|---|---|
run_started, run_finished |
a run begins and ends (run_started records the requested and achieved isolation) |
network_allowed, network_blocked |
the egress proxy allowed or refused a connection. They are written when the run ends, from the proxy's own log, so they come after the run's closing entry (run_finished on a clean exit), and the run's last moments can be missing. |
mcp_tool_blocked, mcp_request_blocked |
the MCP gate refuses a tool or a request |
tool_call_start, tool_call_end |
around each MCP tool call the gate forwards upstream |
gate_triggered, gate_approved, gate_denied, gate_timeout |
a human gate holds a named tool's call and it is answered or times out. Gates hold only calls that pass through the gate. |
gate_signature_invalid, gate_request_id_mismatch, gate_digest_mismatch |
a webhook decision fails verification |
spending_limit_reached |
a cap on priced tool calls that pass through the gate trips, a run is refused, the daily warning threshold is crossed, or a priced response can't be metered |
terminated_by_limit |
max_duration_seconds elapses, or a spend cap stops the run |
a2a_call_sent, a2a_call_received, a2a_call_rejected |
agent-to-agent round trips |
Signing and the hash chainLink to this section
When the agent has an identity (identity.did), each entry carries three extra fields:
| Field | Content |
|---|---|
did |
the signing agent's did:key identifier |
prev_hash |
hex SHA-256 of the previous raw log line, or an all-zero genesis value for the first line of a file |
sig |
base64 Ed25519 signature over the serialized entry (with sig itself absent) |
Because sig is always the last JSON field, the signed bytes are the raw line with its ,"sig":"…" field taken out. Verification operates on the very bytes on disk, with no re-canonicalization. The chain resumes across runs: a new run's first entry chains to the last line already in the file.
The chain lets constle audit verify check the lines as a sequence, not only one by one:
| Tampering | Reported as |
|---|---|
| Editing a line | invalid_signature, at the exact line |
| Deleting a line that has another after it | chain_break_missing_entry, at the exact position |
| Reordering lines | chain_break_reordered, at the exact position |
| Rewriting the whole log with a different key | did_mismatch, when the DID is pinned |
A signed log shows each entry is unchanged, not that the log is complete. Lines cut from the end of a file leave a shorter chain that is still intact: the log alone cannot show that it was once longer. For the same reason, an older copy of the same log, or another complete log signed by the same key, verifies too. To prove a log was not later cut short or replaced, keep a copy or its hash where the agent's host cannot change it (limitation 6).
Verifying a logLink to this section
constle audit verify --agentfile=examples/basic-agent/agent.yaml ~/.constle/logs/basic-agent-$(date -u +%F).jsonl✓ audit log verified: /home/you/.constle/logs/basic-agent-<date>.jsonl
entries: 2 (all signatures valid, hash chain intact)
signed by: did:key:z6MkgroKowQYDZjDmqbn82mJv4YFPKowS2xDhxGYrp4u3P1o
pinned: DID matches the expected identity
pins from: examples/basic-agent/agent.yaml (identity.did only — it declares no approver_pubkey, so gate decisions are unpinned)After one byte inside an entry is edited, for example:
error: TAMPERING DETECTED in /home/you/.constle/logs/basic-agent-<date>.jsonl
line 1: invalid_signature — signature does not verify against did:key:z6MkgroKowQYDZjDmqbn82mJv4YFPKowS2xDhxGYrp4u3P1o — the entry was edited after signingverify recovers each key from the DID string itself, offline, with no registry. It checks every signature and the whole chain, and re-verifies the human-gate decisions the log records.
Important
Pin the trust anchors
--agentfile=<path> takes both pins from the Agentfile: the agent's identity.did and human_gates.approver_pubkey. --did= and --approver-pubkey= pin them one at a time. Unpinned, a check shows only that the log is consistent with its own key, and the output says so (signatures are valid under the log's own key, which was not pinned): a log rewritten wholesale with another key would verify against itself.
Recorded gate decisionsLink to this section
When one of the calls that pass through the gate is decided over the signed webhook, the log records the decision's signed fields, the identifying fields of the request (never tool_call.arguments, which routinely carry secrets) and the approver key actually used. The approver's decided_at is unsigned. The log uses the entry's own time. Verification compares that cryptography against the event the log claims, in both directions:
- A
gate_approvedthat does not re-verify as approved is an approval the declared approver never gave. - A
gate_signature_invalidthat now verifies cleanly is a denial the log misattributes to the approver. - An approval with no recorded decision fails, unless it says
decided_by: terminal, because a terminal approval signs nothing and leaves nothing to re-verify.
What an intact chain provesLink to this section
| Proves | Does not prove |
|---|---|
| No signed entry was changed, inserted or reordered since it was signed. | That the account is true: the host wrote it, and the host is trusted by construction. |
| The lines present are signed and in their original order, with none missing between them. | That none were cut from the end, or that this is the current copy of the log. |
A recorded webhook approval was signed by the pinned approver key over that subject_digest. |
Which tool call a signed digest was for: the recorded request omits the arguments, so a genuine decision could be relabelled (the request_id settles it against the approver's own records). |
With identity.did declared and no matching key on the machine, constle run refuses to start, so a run can't silently fall back to an unsigned log. Without an identity, a run writes its log unsigned, and verify reports such a log as not signed. See Identity.