---
title: "AI Agent Audit Log"
description: "Validate declared authorization phases and detect audit-log tampering with a runnable hash-chain checker."
source: "https://agntc.work/blog/ai-agent-audit-log/"
pubDate: "2026-09-22T00:00:00.000Z"
category: "security-governance"
author: "Erez Eden"
proficiencyLevel: "Advanced"
tags: ["AI Agent Audit Log","Agent Governance","Audit Trail","OpenTelemetry","Security"]
---
# Executive Summary
This runnable AI agent audit-log validator checks declared authorization phase, parent links, session continuity, timestamps, and SHA-256 hash chaining. It catches changes to earlier records, but it does not prove that an external tool completed or that the final system state is correct.

# Key Takeaways
- A pre-execution decision record declares that an allow, deny, or escalate decision was written before an external side effect; the validator checks that phase and chain position, not the external clock.
- A hash chain detects later changes to earlier records only when the attacker cannot rewrite or re-sign the entire store.
- Tool outcomes and final-state checks need separate post-execution evidence; this artifact deliberately does not simulate them.

---

An AI agent can send messages, move money, and change records. A prompt transcript does not prove that authorization ran first. This runnable validator checks a narrower contract: every denied or escalated decision record declares `pre_execution` and occupies the checked chain position, session order is intact, and later edits break the SHA-256 hash chain. It does not prove that a tool completed or the final state is correct.

## What the artifact does

The validator accepts JSONL audit records and checks:

- mandatory identity, session, action, outcome, and phase fields;
- controlled action, outcome, and phase values;
- parent links and one session across the chain;
- non-decreasing timestamps;
- a SHA-256 link to the canonical previous record;
- `pre_execution` phase for denied or escalated decisions.

With no input file, it builds a clean two-record fixture, validates it, changes the first record after the second hash is set, and validates again. The executed artifact returned:

```json
{
  "clean_errors": [],
  "tampered_errors": ["1: broken hash chain"],
  "phase_cases": {
    "denied_pre_execution": [],
    "denied_post_execution": ["0: denied decision must be pre_execution"],
    "escalated_pre_execution": [],
    "escalated_concurrent": ["0: escalated decision must be pre_execution"]
  }
}
```

It exited `0`. The clean chain validates; changing the earlier record breaks the chain; denied and escalated decisions pass only when they declare `pre_execution`. These checks validate declared fields and chain order, not an external side-effect clock.

## Design decisions

**Record authorization before action.** A post-action trace shows that something happened. It cannot prove that a policy gate controlled the side effect. In my test, the fixture starts with a `decision` event whose `record_phase` is `pre_execution`; the validator checks the declaration and chain order, not when an external side effect occurred.

**Keep the vocabulary small.** The artifact accepts `tool_call`, `tool_response`, `decision`, `delegation`, `escalation`, `error`, and `lifecycle`. Outcomes are `success`, `failure`, `timeout`, `denied`, or `escalated`.

**Link records, not raw secrets.** Each record points to its parent and carries the SHA-256 digest of the canonical prior record. Real systems should hash or securely reference sensitive payloads rather than copying credentials, personal data, or full prompts into an analytics store.

**Separate operational traces from audit evidence.** [OpenTelemetry's GenAI agent conventions](https://github.com/open-telemetry/semantic-conventions-genai/blob/c88d504ab3d9879f8e50d3cc87e69775e11db234/docs/gen-ai/gen-ai-agent-spans.md), verified September 19, 2026, define development-status spans for agents, workflows, plans, and tool execution. Those spans are a good operational base. Authorization phase and tamper evidence remain explicit controls in this artifact.

The field vocabulary also draws from the September 2026 [IETF Agent Audit Trail draft](https://datatracker.ietf.org/doc/html/draft-sharif-agent-audit-trail-03). It is an Internet-Draft, not an adopted standard. Its useful ideas here are pre-execution records, independent recording, controlled action types, and chained hashes.

## Run it

Download the complete [AI agent audit-log validator](https://agntc.work/artifacts/ai-agent-audit-log.mjs). Save it as `ai-agent-audit-log.mjs`, then run `node ai-agent-audit-log.mjs`. To validate a file, run `node ai-agent-audit-log.mjs audit.jsonl`.

The complete downloadable source is reproduced below for inspection:

```js
import { createHash, randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';

const required = [
  'record_id', 'timestamp', 'agent_id', 'agent_version', 'session_id',
  'action_type', 'action_detail', 'outcome', 'parent_record_id',
  'prev_hash', 'record_phase',
];
const actions = new Set(['tool_call', 'tool_response', 'decision', 'delegation', 'escalation', 'error', 'lifecycle']);
const outcomes = new Set(['success', 'failure', 'timeout', 'denied', 'escalated']);
const phases = new Set(['pre_execution', 'post_execution', 'concurrent']);

function canonical(value) {
  if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
  if (value && typeof value === 'object') {
    return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
  }
  return JSON.stringify(value);
}
function digest(record) {
  return createHash('sha256').update(canonical(record)).digest('hex');
}
function validate(records) {
  const errors = [];
  const ids = new Set();
  for (let i = 0; i < records.length; i++) {
    const r = records[i];
    for (const field of required) if (!(field in r)) errors.push(`${i}: missing ${field}`);
    if (ids.has(r.record_id)) errors.push(`${i}: duplicate record_id`);
    ids.add(r.record_id);
    if (!actions.has(r.action_type)) errors.push(`${i}: invalid action_type`);
    if (!outcomes.has(r.outcome)) errors.push(`${i}: invalid outcome`);
    if (!phases.has(r.record_phase)) errors.push(`${i}: invalid record_phase`);
    if (Number.isNaN(Date.parse(r.timestamp))) errors.push(`${i}: invalid timestamp`);
    if (i === 0) {
      if (r.parent_record_id !== null || r.prev_hash !== null) errors.push('0: genesis must have null parent and hash');
    } else {
      const prev = records[i - 1];
      if (r.session_id !== prev.session_id) errors.push(`${i}: session changed`);
      if (r.parent_record_id !== prev.record_id) errors.push(`${i}: broken parent link`);
      if (r.prev_hash !== digest(prev)) errors.push(`${i}: broken hash chain`);
      if (Date.parse(r.timestamp) < Date.parse(prev.timestamp)) errors.push(`${i}: timestamp moved backwards`);
    }
    if (r.action_type === 'decision' && ['denied', 'escalated'].includes(r.outcome) && r.record_phase !== 'pre_execution') {
      errors.push(`${i}: ${r.outcome} decision must be pre_execution`);
    }
  }
  return errors;
}

if (process.argv[2]) {
  const text = await readFile(process.argv[2], 'utf8');
  const records = text.trim().split('\n').filter(Boolean).map(JSON.parse);
  const errors = validate(records);
  console.log(JSON.stringify({ records: records.length, valid: errors.length === 0, errors }, null, 2));
  process.exitCode = errors.length ? 1 : 0;
} else {
  const session = randomUUID();
  const first = {
    record_id: randomUUID(), timestamp: '2026-09-19T05:00:00.000Z',
    agent_id: 'urn:agent:invoice-bot.example', agent_version: '1.0.0', session_id: session,
    action_type: 'decision', action_detail: { decision_type: 'approve', policy_ref: 'invoice-limit-v3' },
    outcome: 'success', parent_record_id: null, prev_hash: null, record_phase: 'pre_execution',
  };
  const second = {
    record_id: randomUUID(), timestamp: '2026-09-19T05:00:00.100Z',
    agent_id: first.agent_id, agent_version: first.agent_version, session_id: session,
    action_type: 'tool_call', action_detail: { tool_name: 'create_invoice', parameters_hash: 'demo' },
    outcome: 'success', parent_record_id: first.record_id, prev_hash: digest(first), record_phase: 'post_execution',
  };
  const clean = validate([first, second]);
  first.outcome = 'failure';
  const tampered = validate([first, second]);
  const decisionCase = (outcome, record_phase) => validate([{
    ...first, record_id: randomUUID(), session_id: randomUUID(),
    outcome, record_phase, parent_record_id: null, prev_hash: null,
  }]);
  const phase_cases = {
    denied_pre_execution: decisionCase('denied', 'pre_execution'),
    denied_post_execution: decisionCase('denied', 'post_execution'),
    escalated_pre_execution: decisionCase('escalated', 'pre_execution'),
    escalated_concurrent: decisionCase('escalated', 'concurrent'),
  };
  console.log(JSON.stringify({ clean_errors: clean, tampered_errors: tampered, phase_cases }, null, 2));
  const phasesPass = phase_cases.denied_pre_execution.length === 0 &&
    phase_cases.denied_post_execution.includes('0: denied decision must be pre_execution') &&
    phase_cases.escalated_pre_execution.length === 0 &&
    phase_cases.escalated_concurrent.includes('0: escalated decision must be pre_execution');
  process.exitCode = clean.length === 0 && tampered.includes('1: broken hash chain') && phasesPass ? 0 : 1;
}
```

The inline block is byte-identical to the artifact I executed.

## Limits and when not to use it

This validator does **not** prove that `create_invoice` ran, that the invoice is correct, or that a human approved it. Add a linked `tool_response` or `error` record and verify the final system state separately. The [agent evaluation framework](/blog/agent-evaluation-frameworks) covers that split.

The compact canonicalizer works for the JSON values in this example. For cross-system signatures, use a reviewed RFC 8785 implementation. A hash chain is only tamper-evident when the agent cannot rewrite the full store and recompute every hash. Put the recorder in a gateway, sidecar, or append-only system outside the agent's write authority.

Do not use this artifact as a compliance certification. Retention, access controls, deletion rights, signatures, external time anchors, and regulatory mappings depend on the deployment.

Browse the [Security & Governance topic](/category/security-governance/) for related controls. The [Permission-Aware RAG article](/blog/permission-aware-rag) explains how to filter unauthorized context before retrieval. Track the runtime burden with [cost per verified outcome](/blog/agent-cost-per-outcome).

## Download and next step

Download the validator into your project and replace the fixture with records from one external write. Add a gateway test that refuses the write when the pre-execution authorization record is missing. Then add the post-execution tool outcome and final-state check this small validator deliberately leaves out.
