# Tamper-evident audit trails for AI agents that prove actions

AI agents do not need a better chat transcript. They need evidence produced at the point where an action leaves the machine.

That distinction decides whether you can answer an incident question such as "Did this agent push to production, with which process, under whose approval, and what did the remote system return?" A conversational recap cannot carry that weight. A tamper-evident journal can, provided you are precise about what it proves and what it does not.

I have seen teams treat an agent's terminal output as an audit record because it was easy to save. That is backwards. Terminal output is useful during debugging; it is poor evidence because the process that wrote it may have failed before the side effect, may have redacted the important argument, or may have been replaced entirely.

The boring answer wins here.

An action gateway should create the record as it executes an HTTP call or SSH command, seal that record into an ordered journal, and make later changes visible. Then an investigator has an artifact that does not depend on the agent's memory, prompt, or willingness to confess.

## A transcript and an audit trail answer different questions

A transcript says what the agent claims it attempted; an audit trail says what the action boundary accepted and returned. Conflating those two artifacts leaves a gap exactly where autonomous work becomes risky.

Consider an agent that says it ran `terraform apply` after opening an SSH session. That text does not establish that the SSH connection succeeded, that the command reached the intended host, or that the host accepted it. It might be a planned command printed before execution. It might be a command that failed on line one. It might have been altered by a wrapper after the fact.

A useful action record binds together facts that come from different layers:

- the agent run that asked for the action
- the local process identity that received authorization
- the channel and destination, such as SSH host or HTTP origin
- a normalized action summary and result status
- the journal position and predecessor hash

The record does not need to become a surveillance dump. In fact, writing every request body and full response body into a permanent journal is often reckless. A deployment API may return an access token. An SSH command can include a temporary secret in an environment assignment. Capture the facts needed to reconstruct the security decision, then put strict limits on sensitive payload retention.

NIST SP 800-92 treats log management as a process that spans generation, storage, analysis, and disposal, rather than a folder where applications pour text. That old guidance maps surprisingly well to agents: generation must happen at the executor, storage must survive the incident, and analysis must not require trusting the actor under investigation.

The action boundary is where truth gets narrower. That is good.

If an agent never receives the credential and instead asks a local gateway to make the call, the gateway can log the request it actually sent. The agent can still be compromised. It cannot silently rewrite the gateway's completed-action history from inside its own context.

## Tamper-evident does not mean immutable

Tamper-evident means a verifier can detect certain changes; immutable means nobody can change or remove data. Those are separate promises, and vendors regularly blur them because "immutable" sounds easier to buy.

A hash chain gives each record a dependency on the record before it. A simplified record might look like this:

```text
record_1042 = {
  sequence: 1042,
  run_id: "run_7f3c",
  time: "2026-03-18T14:22:09Z",
  action: "ssh.exec",
  destination: "deploy@release-host",
  result: "exit 0",
  previous_hash: "a4c1...",
  hash: "8d72..."
}
```

The implementation serializes the fields deterministically, calculates a hash over that serialization, and places the prior record hash inside the next record. Change `exit 0` to `exit 1`, alter the host, or reorder two entries, and verification fails from that point onward.

NIST's Secure Hash Standard defines approved hash algorithms that generate fixed-length message digests. A digest is not encryption and it is not a signature. It is the compact binding material that lets a later record depend on exact earlier bytes.

That catches edits. It does not catch every form of dishonesty.

An attacker who can rewrite the journal and recompute every later hash can produce a chain that verifies. An attacker who deletes the final 40 records can leave a valid earlier prefix. An attacker who maintains two separate chains can show different histories to different reviewers. The chain tells you that a presented sequence is internally consistent. It cannot, by itself, establish that the sequence is complete or globally unique.

This is why I push back when someone calls a local hash chain "nonrepudiation." It is not. Nonrepudiation needs an identity binding and a verification story that survives the party accused of acting. A local chain is still worth having, but call it what it is: strong evidence against casual editing and storage corruption.

## A chain needs an anchor outside the writer's reach

A hash chain becomes much harder to rewrite when someone preserves checkpoints that the journal writer cannot quietly replace. Without an external reference, the attacker who owns the writer also owns the beginning of your story.

There are several ways to anchor a journal. They differ in cost and operational fit.

- Export signed checkpoints to a separate account with narrowly scoped write access.
- Send periodic journal roots to a security event store that the agent host cannot administer.
- Replicate encrypted journal segments to offline or append-only storage.
- Have an independent monitor retain observed roots and report inconsistencies.

Do not mistake more destinations for better evidence. Five copies in cloud buckets controlled by the same administrator can fail together. Separation of authority matters more than replication count.

Certificate Transparency provides the useful mental model. RFC 9162 uses Merkle trees to support append-only logs, inclusion proofs, and consistency proofs between published tree heads. The RFC also states the hard part plainly: a dishonest log can try to show inconsistent views to different clients.

A simple hash chain is not a Merkle tree. It gives you efficient sequential verification, not compact proofs for arbitrary entries or a public consistency protocol. That is usually the right trade for a single Mac executing a modest volume of agent actions. Adding a Merkle service because the word appears in transparency papers is needless machinery unless you have independent monitors, many verifiers, or a need to prove membership in a large shared journal.

The cost of anchoring is real. You must operate another storage boundary, decide checkpoint cadence, handle outages, and retain enough metadata to associate a checkpoint with a local journal. A checkpoint every action creates chatter and more failure cases. A checkpoint once per day leaves a long interval where truncation may be hard to expose. Pick an interval that matches the speed at which bad actions become expensive.

For production deployment credentials, I would rather anchor every completed privileged action than debate whether daily export is technically adequate. For low-risk read-only API calls, a periodic root can be enough.

## Encryption hides the record, not the history

Encrypted journals protect sensitive action details from casual readers, but encryption alone says nothing about whether records changed. Teams often build one property, describe both, and find the difference only during an investigation.

Suppose a journal entry contains an HTTP path, an authorization credential label, request metadata, and a returned error. Encrypting that entry keeps a disk thief from learning which customer endpoint the agent touched. It does not stop a privileged local process from replacing the ciphertext blob with another ciphertext blob. You need authenticated encryption for each record and a chain across the ciphertext records or their authenticated representations.

The clean design is to make integrity verification possible without exposing contents. The verifier reads the encrypted record sequence, checks framing and predecessor relationships, recomputes the chain, and reports whether the presented bytes still form the expected history. It does not need the credential vault unlocked to decide that record 1042 no longer follows record 1041.

That separation pays off during containment. An investigator can copy the journal, run verification, and preserve the result before asking anyone to unlock sensitive material. The person doing first-pass incident work should not need broad access to deployment credentials just to determine whether the evidence was altered.

Encryption also forces a retention decision. If nobody can decrypt old entries after a device migration, your integrity proof may remain intact while the journal loses operational meaning. Keep recovery and retention procedures separate from the audit format. Do not stash the decryption secret beside the encrypted archive and call the problem solved.

On macOS, hardware-backed protection can narrow exposure of local secrets. Apple's security documentation describes the Secure Enclave as an isolated hardware security processor, and Apple's developer documentation notes that plaintext secret material cannot be transferred into or out of the Secure Enclave mechanism it describes. That supports a local vault design, but it does not magically authenticate every process on the Mac.

The journal must still identify which process received approval and which action crossed the gateway. Hardware protection guards one boundary. Audit evidence needs several.

## The failure usually starts before the dangerous command

A plausible agent incident rarely begins with a command that looks evil. It begins with a mundane request that inherits too much authority.

Imagine a coding agent receives an issue asking it to diagnose a failed release. It reads the repository, sees a deployment script, and opens an SSH channel to a release host. The first command is harmless:

```text
systemctl status web.service
```

The output includes a path to a temporary configuration file. The agent then reads that file, finds a deployment credential reference, and runs a second command that changes an environment value. The remote command returns exit code 0. Twenty minutes later, a customer reports failed requests.

Where could controls have stopped or exposed this?

First, the vault gate could have prevented all external actions while locked. That does not decide whether the command was wise, but it prevents a background agent from using credentials after the user has closed the security boundary.

Second, session authorization could have shown the identity of the agent process before its first action. This is where code-signing authority matters. A familiar editor process and an unsigned helper launched from `/tmp` should not receive equal treatment just because both speak MCP.

Third, an approval requirement on the release-host credential could have interrupted the second action. That prompt should show enough destination and operation context for a human to recognize that this is no longer diagnosis. A generic "allow SSH" button does not meet that standard.

Finally, the journal must show the two commands as distinct completed actions under the same run. If the incident record has only "agent investigated deployment failure," it hides the moment the run changed from observation to mutation.

I prefer per-use approval for credentials that can alter production state. It is slower. It also forces a human to confront the specific moment where a diagnostic task crosses into operational change.

Do not require a click for every harmless request. People will approve a wall of identical prompts without reading them (the human interface will teach them to do exactly that). Put the friction on credential uses whose blast radius changes with each call.

## Process identity belongs in the record

An audit trail that says "Claude Code used SSH" is too vague to settle a dispute. You need to know which local process started the run, what signing authority the operating system reported, and whether the approval covered that process instance.

This is another definition teams flatten: application identity is not process identity. A brand name can describe a legitimate product while a compromised extension, copied binary, or shell wrapper invokes the same protocol from a different executable context. The approval decision should attach to the actual process that asked to act, then expire when that run exits.

The log should capture a stable run identifier and enough process provenance to answer these questions later:

- Did the same process make the first and last request?
- Did the user approve this run before it performed the call?
- Was the run revoked before a later request arrived?
- Did an unrelated local process attempt to reuse the channel?

Avoid recording a mutable display name as your strongest identity field. Names change. Paths can be replaced. A signing authority or equivalent platform identity is more useful because it connects the approval screen, the session journal, and the later investigation.

There is a cost. Legitimate development builds, local forks, and unsigned tools will create more review work. That is not a defect in the audit model. It is the visible price of allowing experimental software to access real credentials. Decide whether those tools should use a separate low-privilege credential rather than teaching reviewers to ignore unfamiliar identity details.

The record also needs to distinguish denied attempts from completed actions. A denied SSH request is evidence of a control working. A failed connection is evidence of an attempted path, not proof of remote execution. A completed request with a returned result is stronger still. Do not collapse those outcomes into one boolean called `success`.

## One journal for runs and calls creates a better timeline

Session events and action events should project from the same ordered source of truth, even though operators read them for different reasons. Separate log files written by separate components drift apart under pressure.

The session view answers questions about agent lifecycle: when a run appeared, which process it belonged to, whether a user authorized it, and whether someone revoked it. The activity view answers questions about individual actions: which credential label was selected, which destination was used, whether the call was allowed, and what result came back.

Those views should not be independent sources. If an action appears without its session, or a revoked run appears to keep issuing calls, an investigator needs one sequence to determine whether the data is inconsistent or the system behavior is wrong.

This is where a write-blind encrypted journal has a practical advantage. Components can append the information they are entitled to report while the journal format prevents them from casually browsing unrelated entries. The security gain does not come from making logs mysterious. It comes from reducing the number of code paths that can read, edit, and reinterpret historical records.

Sallyport projects both a Sessions journal and an Activity journal from one encrypted, hash-chained audit log, and `sp audit verify` checks that chain offline over ciphertext without needing the vault unlocked. That is the right shape for local agent evidence because the verification path stays available during containment.

I would still export checkpoints for high-consequence environments. Local verification tells you whether the copy in hand has internal integrity. An external checkpoint helps you notice that someone handed you an older, shorter history.

Keep journal presentation separate from journal truth. A convenient UI can filter, group, and redact entries for daily use. The underlying verifier must operate on stable bytes and deterministic ordering, not on whatever the current interface happens to display.

## Verification must be routine, not ceremonial

A verification command that runs only after an incident is a feature nobody has tested. Put it into ordinary maintenance and make failure handling unsurprising.

For a Sallyport installation, the concrete first check is:

```bash
sp audit verify
```

Run it against a copied journal before a major upgrade, before wiping a development Mac, and when you suspect that local state changed unexpectedly. Preserve the exact journal copy you verified. Re-running the command later on a different copy does not establish what existed during the incident.

The command alone is not enough. Pair it with a small operating routine:

1. Retain the journal copy and its checkpoint reference before remediation.
2. Record the affected run identifier and the time window you are investigating.
3. Compare completed HTTP and SSH actions with the remote provider's own logs.
4. Revoke the active run before asking the agent to perform cleanup.
5. Verify again after export or transfer to confirm the copied evidence survived.

The third item matters because local integrity and external truth complement each other. A valid journal can show that the gateway issued `POST /deployments`; the deployment provider can show whether it accepted, queued, or rejected the operation. SSH exit status can say a shell command completed; the host's service logs can show the process that acted afterward.

I have little patience for audit systems that only render events in a dashboard. If you cannot validate the stored journal without the dashboard, you have made incident response dependent on the same application stack that may be under question.

Verification should also fail loudly on missing segments, invalid sequence numbers, malformed ciphertext framing, or a predecessor mismatch. A tool that skips bad records to produce a prettier timeline is an evidence shredder with good manners.

## Approval records need the same rigor as action records

An approval click is part of the security event, not a decorative UI detail. If a system records only that an action was allowed, you cannot later establish whether the user approved a specific run, a credential category, or an entirely different prompt.

Store the decision context that the person actually saw: the requesting process identity, the scope of approval, the relevant credential label, and the time. For a session-level decision, record when the session began and when it ended or was revoked. For a per-use decision, tie the approval to one attempted action so it cannot silently authorize a later destination.

A good approval boundary carries a blunt tradeoff. Broader session authorization reduces interruptions and makes autonomous loops usable. Narrow per-call authorization gives a human more chances to stop a bad turn, but it becomes exhausting if you apply it to routine reads. Neither setting is universally safer; the credential's effect determines the sensible scope.

The three-control model is often enough: keep the vault locked until the user opens it, authorize an identified process for one session, and require explicit confirmation for selected credentials on every use. A rules language may look more sophisticated, but every condition becomes another claim people must test during a release. For a desktop action gateway, I would choose a small fixed set of controls over a policy engine that nobody can explain at 2 a.m.

That choice excludes some workflows. You cannot express every environment exception or time-based policy in a fixed decision ladder. Teams with a server fleet and complex delegation may need a different system. Pretending that a local menu-bar app should become a general authorization server is how a focused security boundary turns into a fragile one.

## Decide what you need to prove before granting access

The useful question is not "Do we have logs?" It is "Which claim will we need to establish when this run goes wrong?"

For each credential an agent may use, write the claim in plain language. Examples include: this signed process received authorization for this run; this run called this API endpoint; this SSH command crossed the local gateway; this credential was locked at the time of a denied request; these records have not been modified since the retained checkpoint.

Then test the journal against an awkward scenario. Delete its last 50 lines. Change one destination field. Copy it to another Mac. Lock the vault and attempt verification. Revoke a run between two calls. If you cannot say which control should stop the event and which record should show it, you have observability, not evidence.

A hash chain will not make an unsafe credential safe. Encryption will not prove completeness. An approval prompt will not rescue an inattentive reviewer. Each control has a narrower job.

Build the record where the action occurs, protect its contents, chain its ordering, retain an anchor beyond the writer, and rehearse verification before an agent has access to anything that can hurt you. That sequence is less glamorous than a giant policy engine. It is also far easier to defend when someone asks what actually ran.
