Get release news

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

shell
constle audit verify --agentfile=examples/basic-agent/agent.yaml ~/.constle/logs/basic-agent-$(date -u +%F).jsonl
Output
✓ 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:

Output
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 signing

verify 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_approved that does not re-verify as approved is an approval the declared approver never gave.
  • A gate_signature_invalid that 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.