Command Palette

Search for a command to run...

UnylyUnyly
Browse all

Audit Event

FreeNot checked

Hash-chained audit log for AI agents — MCP-native, Cloudflare Durable Objects + SQLite

GitHubEmbed

About

Hash-chained audit log for AI agents — MCP-native, Cloudflare Durable Objects + SQLite

README

Agent steward infrastructure — hash-chained audit log with Merkle notarisation and a GDPR/AI-Act compliance skill.

CI npm npm downloads License: MIT Smithery


What it does

  • Records agent events (tool calls, decisions, human turns) to a per-client SQLite log with a SHA-256 hash chain — every record is cryptographically linked to the previous one
  • Optionally notarises batches with a Merkle root + Ed25519 signature (paid tier) — external auditors can verify without contacting kajaril
  • Exports all events for a data subject as NDJSON on demand (GDPR Art. 20 portability)
  • Ships a Claude Code skill that runs 8 GDPR/AI-Act axes against any event store

MCP endpoint

https://audit-event.kajaril.com/mcp

JSON-RPC 2.0 over HTTPS, authenticated via Cloudflare Access (service token) or an OAuth client-credentials access token — see Authentication. Contact [email protected] for onboarding.

Tools

Tool Description
record_event Write one audit event. Returns { id, chain_hash }.
verify_chain Recompute chain_hash for a range of events. Returns verified count and broken entries.
query_events Filter by session, agent, event type, or date range. input_hash is never returned.
export_dossier Export all events for a subjectId as NDJSON (GDPR Art. 20). Returns a 1-hour download URL.
request_approval Ask a human to approve an action. Returns approvalId, an approvalUrl approve page, and your webhookSecret. Witnessed as approval.requested in the chain.
check_approval Poll an approval: pending | approved | denied | timeout, with reason and responder once decided.

Quick start

Record an event:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "record_event",
    "arguments": {
      "agentId": "travel-assistant",
      "eventType": "tool.call",
      "purpose": "User requested flight search via travel assistant",
      "sessionId": "550e8400-e29b-41d4-a716-446655440000",
      "input": { "tool": "search_flights", "origin": "LHR", "dest": "JFK" },
      "lawfulBasis": "contract",
      "subjectId": "user-8821"
    }
  }
}

Verify the chain:

{ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
  "params": { "name": "verify_chain", "arguments": { "limit": 100 } } }

Export a data subject's records:

{ "jsonrpc": "2.0", "id": 3, "method": "tools/call",
  "params": { "name": "export_dossier", "arguments": { "subjectId": "user-8821" } } }

Authentication

Three ways in; all end at a signature-verified client_id that selects your tenant — nothing user-supplied ever does.

Cloudflare Access service token (manual onboarding): send the token headers, CF Access injects a verified JWT. Full surface.

OAuth browser flow (MCP clients, zero copy-paste): point your client at the server and let it discover the rest —

claude mcp add --transport http audit-event https://audit-event.kajaril.com/mcp

RFC 8414 metadata advertises dynamic client registration (/oauth/register), authorization with PKCE S256 (/oauth/authorize), and the shared token endpoint. Consent is approved in the browser by a signed-in tenant operator; the granted scope (agent or admin) is bound into the issued token.

OAuth client-credentials (M2M): exchange a client secret for a 1-hour Bearer token at POST /oauth/token (application/x-www-form-urlencoded, HTTP Basic or client_id / client_secret body params):

curl -s https://audit-event.kajaril.com/oauth/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=client_credentials -d scope=agent

Two scopes, issued and rotated independently:

Scope May call
agent record_event, request_approval, check_approval
admin everything, plus POST /credentials/rotate

Secrets are shown once at issue/rotation (POST /credentials/rotate, body {"scope":"agent"}) and stored only as hashes in your tenant's Durable Object. Rotating a scope invalidates its old secret immediately; outstanding access tokens expire within the hour.

The approval tools are also plain REST for production backends: POST /approvals (same body as request_approval) and GET /approvals/:id, with Authorization: Bearer <access token>.

How it works

Hash chain

Every event commits to all events before it. The chain is recomputable from first principles:

input_hash_slot = input_hash ?? input_hash_omitted_reason
chain_hash      = SHA-256(id + "|" + event_type + "|" + input_hash_slot + "|" + prev_hash)

When a caller supplies inputHashOmittedReason instead of input, the reason string enters the chain — the omission itself is tamper-evident.

Merkle notary (paid tier)

Every 15 minutes (or at 1,000 pending events), the notary Worker:

  1. Collects { id, chain_hash } pairs from pending records
  2. Sorts them by id and builds a SHA-256 binary Merkle tree
  3. Signs the root with Ed25519
  4. Writes merkle_root + notary_sig back to each record

The notary public key is published at /.well-known/notary-pubkey (also proxied same-origin on go.kajaril.com for the verify page). Any auditor can verify signatures offline without contacting kajaril. The notary never receives input_hash, payload_ref, or any payload content.

Verifying a dossier

export_dossier returns a link to a human-readable dossier on go.kajaril.com with a raw JSONL evidence file attached. Anyone can drop that file onto https://go.kajaril.com/verify — the browser recomputes every record's chain fingerprint from its exported preimage (id | event_type | input_hash ?? omitted_reason | prev_hash) and checks each notary Ed25519 signature against the published key. Nothing is uploaded; the verdict is computed client-side.

Privacy design

  • input is hashed locally; the raw value is not stored unless an R2 bucket is explicitly bound
  • input_hash is excluded from query_events responses; dossiers export it (with prev_hash) as the chain preimage that makes independent verification possible — they are digests, never content
  • payload_ref is never returned to callers — enforced at every handler boundary
  • An empty export_dossier result (eventCount: 0) is valid GDPR evidence that no data exists for a subject

Event types

tool.call | tool.result | decision.made |
human.turn | memory.read | memory.write | error.raised |
approval.requested | approval.decided

approval.* types are reserved: the witness records them itself when a human approval is requested and decided. record_event refuses them — an agent must never be able to fabricate human-decision evidence. (approval.deferred and approval.escalated are reserved for later.)

Decision webhooks

When an approval carries a callback_url, the decision (approve/deny) is POSTed to it the moment a human decides — this is what lets an interrupted agent resume instead of poll. Timeouts never fire a webhook; polling resolves those.

Every delivery is signed. The body is JSON:

{
  "type": "approval.decided",
  "approval": { "id": "…", "status": "approved", "reason": null, "responderId": "…",
                "agentId": "…", "sessionId": "…", "actionSummary": "…",
                "actionPayloadHash": "…", "createdAt": "…", "decidedAt": "…", "expiresAt": "…" },
  "chainEvent": { "id": "…", "chainHash": "…" }
}

chainEvent points at the approval.decided entry in the hash chain — cite it as evidence.

Verifying the signature. Your webhook secret (whsec_…) is returned in every request_approval response as webhookSecret — no dashboard needed. Each delivery carries:

X-Kajaril-Signature: t=<unix seconds>,v1=<hex>

To verify:

  1. Read t and v1 from the header. Reject if |now − t| exceeds 300 seconds.
  2. Compute HMAC-SHA256(key = the literal secret string (UTF-8, whsec_ prefix included), message = "{t}." + raw request body).
  3. Hex-encode and compare to v1 with a constant-time comparison.
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));

The reference implementation is verifyWebhookSignature in src/lib/webhook.ts — the same code path our tests run.

Self-hosting note: webhook signing derives per-tenant secrets from the WEBHOOK_SIGNING_SECRET Workers Secret. If it is unset, webhooks are not sent at all (never unsigned) and request_approval returns webhookSecret: null; polling still works.

Notification channels

request_approval accepts channels: ["telegram", "email"] and notifies the approver over a ladder: instant first, email as the fallback.

Telegram (instant). Connect once — POST /channels/telegram/connect (admin scope) returns a one-time t.me deep link (15-minute expiry). Open it, press Start, and approval cards arrive in that chat with inline Approve / Deny / Deny with reason buttons. Deny-with-reason prompts for a short reply that is recorded with the decision — stronger Art. 14 evidence than a binary stamp. Connecting again replaces the bound chat.

Email (fallback). Set the approver address once — POST /channels/email (admin scope) with {"address": "…"}. The email fires only when it is needed:

  • immediately, if no instant channel delivered (not connected, send failed, or not requested);
  • after 10 minutes, if an instant card was delivered but the approval is still undecided;
  • never, if the approval would expire before the email could matter.

Mail is sent from [email protected] with the decide link; replies reach a human via [email protected].

Dispatch report. Every request_approval response includes what actually happened:

"notifications": { "telegram": "sent", "email": "scheduled" }

(telegram: sent | failed | not_connected | unconfigured | not_requested; email: immediate | scheduled | expires_first | no_address | unconfigured | arm_failed | not_requested.)

TTL defaults follow the channels: 30 minutes when an instant channel is in play, 4 hours when the request is email-only, explicit ttlSeconds always wins.

Self-hosting note: channels are optional. Without TELEGRAM_BOT_TOKEN / RESEND_API_KEY / CHANNELS_KV, dispatch reports unconfigured and the approval still works through approvalUrl + polling. The bindings are documented in wrangler.jsonc and wrangler.go.jsonc.

Lawful basis (GDPR Art. 6)

legitimate_interest | contract | legal_obligation |
vital_interest | public_task | consent

Provide lawfulBasis for any event that processes personal data. Omit it for events that do not.

Tiers

Capability Free Paid
Hash-chained event writes yes yes
verify_chain yes yes
export_dossier (GDPR Art. 20) yes yes
R2 payload storage optional optional
Merkle root + Ed25519 notarisation yes
compliance-audit axis 6 (notary coverage) skipped evaluated

compliance-audit skill

skills/compliance-audit/SKILL.md is a Claude Code skill that runs 8 GDPR/AI-Act compliance axes against any event store. Copy it into your workspace:

cp -r node_modules/@kajaril/audit-event-mcp/skills/compliance-audit .claude/skills/

Axes:

  1. Lawful basis present — GDPR Art. 5–6
  2. Purpose specificity — GDPR Art. 5(1)(b)
  3. Subject linkage on human.turn events
  4. Retention bounded (≤ 730 days)
  5. Chain integrity — 10% sample recompute
  6. Notary coverage ≥ 95% (paid tier only)
  7. High-risk event completeness — AI Act Art. 12–13
  8. Data minimisation signal — payload_ref fraction

Each axis produces pass | fail | warn with an evidence snippet. Output is JSON + a markdown summary block.

Self-hosted deployment

Prerequisites

  • Cloudflare account with Workers and Durable Objects enabled
  • wrangler CLI authenticated

Steps

# 1. Create R2 bucket
wrangler r2 bucket create audit-payloads

# 2. Set notary signing key (paid tier)
wrangler secret put NOTARY_PRIVATE_KEY --config wrangler.notary.jsonc
# Value: hex-encoded Ed25519 private key — never committed to source

# 3. Deploy Workers
wrangler deploy
wrangler deploy --config wrangler.notary.jsonc

# 4. Add audit-event.kajaril.com as a CF Access Self-hosted Application
# Issue one service token per client with custom.client_id claim

# 5. Onboard a client
npx tsx src/scripts/onboard-client.ts --client-id acme-crm --tier free --region eu

# 6. Verify
curl https://audit-event.kajaril.com/health

Never run wrangler deploy from a dirty working tree. The deployed code must match git HEAD.

Development

npm install
npm test          # 81 tests (node:sqlite adapter, no cloudflare/vitest-pool-workers)
npm run typecheck
npm run lint

License

MIT. Copyright © 2026 Kajaril Ltd.

from github.com/mightbesaad/audit-event-mcp

Installing Audit Event

This server has no published package — it is built from source. Open the repository and follow its README.

▸ github.com/mightbesaad/audit-event-mcp

FAQ

Is Audit Event MCP free?

Yes, Audit Event MCP is free — one-click install via Unyly at no cost.

Does Audit Event need an API key?

No, Audit Event runs without API keys or environment variables.

Is Audit Event hosted or self-hosted?

A hosted option is available: Unyly runs the server in the cloud, no local setup required.

How do I install Audit Event in Claude Desktop, Claude Code or Cursor?

Open Audit Event on unyly.org, pick your client tab (Claude Desktop, Claude Code, Cursor) and press Install — the config is generated automatically, no JSON editing.

Related MCPs

Compare Audit Event with

Not sure what to pick?

Find your stack in 60 seconds

Author?

Embed badge for your README

Browse similar

All data MCPs