DEV Community

Yuvaraj unakal
Yuvaraj unakal

Posted on

How to Add Tamper-Evident Audit Logs to AI Agents with SHA-256 Chaining

AI agents are moving from chatbots to actors. They don't just generate text anymore — they send emails, query databases, call APIs, and delete records. That shift changes the security requirements entirely.

When an agent takes an action, you need to know:

  • What did it try to do?
  • What decision was made?
  • Was it approved by a human, blocked by policy, or allowed to proceed?

A plain text log file answers those questions — until someone edits it. Then your audit trail is worthless. For compliance, debugging, and incident response, you need a log that proves it hasn't been tampered with.

This post walks through a simple solution: hash-chained audit logs, the same pattern blockchains use, applied at a small scale.

The problem with plain log files

Imagine you have an agent that sends emails. Every time it tries, you write a line to audit.log:

2026-09-20T10:15:00 | send_email | to: boss@company.com | approved
2026-09-20T10:15:32 | send_email | to: external@gmail.com | denied
Enter fullscreen mode Exit fullscreen mode

If someone later edits that second line to say approved, nothing in the file tells you it changed. The timestamps look fine. The format looks fine. But your audit trail is now a lie.

For a hobby project, this doesn't matter. For anything involving money, PII, or a compliance auditor, it matters a lot.

The hash-chain pattern

The fix is to make each entry's integrity depend on every entry before it.

Each log entry stores two hashes:

Its own hash — a SHA-256 digest of its contents

The previous entry's hash

Because the current entry's hash includes the previous entry's hash, changing any entry breaks every hash that comes after it. Verification becomes a single pass: recompute each hash and check that it matches what's stored.

This is the same pattern blockchains use. Applied at small scale, it's roughly 100 lines of Python.

The implementation

Let me walk through the actual code from langgraph-guardrail, a small library I built for policy enforcement in LangGraph agents.

Step 1: The entry structure
Each entry is a dataclass:

from dataclasses import dataclass
from datetime import datetime, timezone

GENESIS_HASH = "GENESIS"

@dataclass
class AuditEntry:
    timestamp: str
    sequence: int
    tool_name: str
    args: dict
    decision: str          #_ "allowed" | "blocked" | "approved" | "denied"_
    reason: str = ""
    prev_hash: str = GENESIS_HASH
    this_hash: str = ""
Enter fullscreen mode Exit fullscreen mode

The first entry uses "GENESIS" as its prev_hash. Every subsequent entry points to the hash of the one before it.

Step 2: Computing the hash
The hash has to be deterministic. If you serialize the entry with json.dumps() without sorting keys, two runs with the same data can produce different hashes:


import hashlib
import json

@staticmethod
def _compute_hash(entry_dict: dict) -> str:
    canonical = json.dumps(entry_dict, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
Enter fullscreen mode Exit fullscreen mode

Two details that matter:

  • sort_keys=True— makes key order deterministic
  • separators=(",", ":") — removes whitespace so the string is canonical

Without these, your chain breaks for no reason on different runs.

Step 3: Appending with chaining
When you append a new entry, it looks up the previous hash first:


def append(self, tool_name, args, decision, reason=""):
    prev_hash, last_seq = self._last_hash_and_sequence()

    entry = AuditEntry(
        timestamp=datetime.now(timezone.utc).isoformat(),
        sequence=last_seq + 1,
        tool_name=tool_name,
        args=args,
        decision=decision,
        reason=reason,
        prev_hash=prev_hash,
        this_hash="",
    )

    entry_dict = asdict(entry)
    entry_dict.pop("this_hash")
    entry.this_hash = self._compute_hash(entry_dict)

    with self.path.open("a", encoding="utf-8") as f:
        f.write(json.dumps(asdict(entry), sort_keys=True) + "\n")

    return entry
Enter fullscreen mode Exit fullscreen mode

The entry is written to a JSONL file — one JSON object per line. Append-only. Easy to grep, easy to parse.

Step 4: Verification
The verify() method walks the chain and checks two things at each step:

def verify(self):
    entries = self._read_entries()
    prev = GENESIS_HASH

    for entry in entries:
        # Check that prev_hash points to the actual previous entry
        if entry.prev_hash != prev:
            return False, entry.sequence

        # Recompute this_hash and compare
        entry_dict = asdict(entry)
        entry_dict.pop("this_hash")
        expected = self._compute_hash(entry_dict)
        if entry.this_hash != expected:
            return False, entry.sequence

        prev = entry.this_hash

    return True, None
Enter fullscreen mode Exit fullscreen mode

It returns the sequence number where the chain breaks, so you know exactly which entry was tampered with.

A working demo
Here's what it looks like in practice. Three decisions logged:

audit = AuditLog("audit.jsonl")
audit.append("query_data", {"sql": "SELECT 1"}, "allowed")
audit.append("send_email", {"to": "a@b.com"}, "approved", reason="External OK")
audit.append("delete_account", {"user_id": 5}, "blocked", reason="Forbidden")

ok, seq = audit.verify()
print(f"Chain intact: {ok}")
Enter fullscreen mode Exit fullscreen mode

Output:

Chain intact: True
Enter fullscreen mode Exit fullscreen mode

Now edit the middle line and change"approved" to "allowed". Run verify() again:

Chain intact: False (failed at sequence 2)
Enter fullscreen mode Exit fullscreen mode

That's the whole point. Tampering is detected, and the exact entry is identified.

Where this fits into a bigger system
The hash chain is one piece. In a production agent setup, you also want:

  • A policy layer — define which tools are allow, block, or require_approval
  • Interrupt-based approvals — pause the agent mid-execution for human decisions
  • Structured audit entries — every decision, not just the final outcome

I wrapped all of that into langgraph-guardrail, a small library for LangGraph agents. The audit log is one module; the policy engine and approval flow are others.

You can install it with:

pip install langgraph-guardrail
Enter fullscreen mode Exit fullscreen mode

And attach it to a LangGraph agent in a few lines:

from langgraph_guard import AuditLog
from langgraph_guard.integrations.langgraph import GuardNode

audit = AuditLog("audit.jsonl")
guard = GuardNode(policy, audit=audit)
Enter fullscreen mode Exit fullscreen mode

Every tool call goes through the guard, and every decision lands in the tamper-evident log.

Takeaway
If you're building agents that take real actions, add a hash-chained audit log. It's roughly 100 lines of code, has no external dependencies, and gives you a defense against the "trust me, it worked" problem.

"Plain logs say what happened. Hash-chained logs prove it"

Top comments (0)