SkillsHooksPromptsAgentsPersonasModelsPoliciesToolsTemplatesBundlesCategoriesStart here
← Hooks
Hk hookgovernanceadvisorystable

Interaction Audit Logger

Appends a JSONL record to ~/.ai/audit/interactions/<YYYY-MM>.jsonl for every Claude Code hook event: SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SessionEnd, SubagentStop, PreCompact. Non-blocking.

id hook/audit-loggerv1.0.0by convergent-systems-key
Event
PreToolUse
Trigger
always
Language
python
Side effects
  • writes to ~/.ai/audit/interactions/<YYYY-MM>.jsonl
Platforms
linuxmacoswindows
Notes
Logic is cross-platform. Wiring: use 'ai hooks run audit-logger' in settings.json — the ai binary discovers Python on each OS. Writes to ~/.ai/audit/ — path resolves correctly cross-platform via Python pathlib.
Depends on
hook/lib

Script · audit-logger.py

#!/usr/bin/env python3
"""hooks/audit.py — interaction audit logger.

Appends one JSONL line per event to
~/.ai/audit/interactions/<YYYY-MM>.jsonl using the
domain-specific vocabulary defined in ~/.ai/Common.md §5.2 (chronon,
trace, cwd, actor, kind, engine, stimulus, probe, probe_payload,
emission_marker).

Wired into Claude Code via every event (SessionStart, UserPromptSubmit,
PreToolUse, PostToolUse, Stop, SessionEnd, SubagentStop, PreCompact, and
additional event types listed in KIND_MAP below).

Reads the event payload from stdin as JSON. Always exits 0 (audit
must never block).

Vocabulary mapping (deliberate non-mirror of Claude/Copilot terms):

    Common term     ->  This system
    ─────────────────────────────────
    session         ->  trace
    turn            ->  exchange
    tool call       ->  invocation / probe
    user message    ->  request / stimulus
    assistant turn  ->  emission
    timestamp       ->  chronon
    model           ->  engine

Self-check:
  --self-check    Verify the audit directory is writable.
"""
from __future__ import annotations

import datetime as dt
import json
import os
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import _lib  # noqa: E402

# Mapping from Claude Code event names to our vocabulary.
# Vocabulary deliberately does NOT mirror Claude or Copilot terms per Common.md §5.2.
KIND_MAP = {
    # Core Claude Code events (PascalCase)
    "SessionStart": "trace-open",
    "SessionEnd": "trace-close",
    "UserPromptSubmit": "request",
    "PreToolUse": "invocation-attempt",
    "PostToolUse": "invocation-result",
    "PostToolUseFailure": "invocation-failure",
    "Stop": "emission",
    "SubagentStart": "subagent-trace-open",
    "SubagentStop": "subagent-emission",
    "Notification": "signal",
    "PreCompact": "compaction-attempt",
    "ErrorOccurred": "fault",
    "PermissionRequest": "permission-prompt",
    # Copilot CLI camelCase event names
    "userPromptSubmitted": "request",
    "agentStop": "emission",
}


def audit_dir() -> Path:
    """Path to ~/.ai/audit/interactions/. AI_ROOT overrides $HOME/.ai."""
    root = os.environ.get("AI_ROOT", str(Path.home() / ".ai"))
    return Path(root) / "audit" / "interactions"


def month_file() -> Path:
    """The audit JSONL for the current UTC month."""
    now = dt.datetime.now(dt.timezone.utc)
    return audit_dir() / f"{now.strftime('%Y-%m')}.jsonl"


def chronon() -> str:
    """ISO-8601 with millisecond precision and 'Z' suffix."""
    now = dt.datetime.now(dt.timezone.utc)
    return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"


def _truncate(s: str, limit: int) -> str:
    if len(s) <= limit:
        return s
    return s[:limit] + "...[truncated]"


def _actor_for(kind: str) -> str:
    if kind == "request":
        return "human"
    if kind in ("emission", "subagent-emission", "signal", "subagent-trace-open"):
        return "assistant"
    if "invocation" in kind or kind == "permission-prompt":
        return "tool"
    return "system"


def normalize_event(raw: dict) -> dict:
    """Map a raw Claude/Copilot event payload to the canonical schema.

    Truncates stimulus to 2000 chars and probe_payload to 1000
    chars per Common.md §5.2. Redacts secrets via _lib.redact()."""
    # Primary key is hookEventName (Claude Code PascalCase); fall back to
    # hook_event_name, then event.
    claude_kind = (
        raw.get("hookEventName")
        or raw.get("hook_event_name")
        or raw.get("event")
        or ""
    )
    kind = KIND_MAP.get(claude_kind, claude_kind.lower() if claude_kind else "signal")

    event = {
        "chronon": chronon(),
        "trace": (
            raw.get("session_id")
            or raw.get("sessionId")
            or raw.get("trace")
            or ""
        ),
        "cwd": raw.get("cwd") or raw.get("workingDirectory") or os.getcwd(),
        "actor": raw.get("actor") or _actor_for(kind),
        "kind": kind,
        "engine": raw.get("engine") or raw.get("model") or raw.get("modelName") or "",
    }

    # Per-kind payload fields — only include fields relevant to the event type.
    if kind == "request":
        stimulus = raw.get("prompt") or raw.get("stimulus") or ""
        event["stimulus"] = _lib.redact(_truncate(str(stimulus), 2000))

    elif kind in ("invocation-attempt", "invocation-result", "invocation-failure"):
        event["probe"] = (
            raw.get("tool_name")
            or raw.get("toolName")
            or raw.get("probe")
            or ""
        )
        payload = (
            raw.get("tool_input")
            or raw.get("toolInput")
            or raw.get("toolArgs")
            or raw.get("probe_payload")
            or {}
        )
        if isinstance(payload, (dict, list)):
            payload = json.dumps(payload)
        event["probe_payload"] = _lib.redact(_truncate(str(payload), 1000))
        # Record that a result is present (don't log content — can be large)
        if kind == "invocation-result":
            result_val = (
                raw.get("tool_response")
                or raw.get("toolResponse")
                or raw.get("toolResult")
                or raw.get("tool_result")
            )
            if result_val is not None:
                event["probe_result_marker"] = "present"

    elif kind in ("emission", "subagent-emission"):
        event["emission_marker"] = (
            raw.get("stopReason")
            or raw.get("stop_reason")
            or raw.get("emission_marker")
            or "stop"
        )
        if raw.get("transcriptPath") or raw.get("transcript_path"):
            event["transcript_marker"] = "present"

    elif kind == "compaction-attempt":
        event["compaction_trigger"] = raw.get("trigger") or ""

    elif kind == "fault":
        err = raw.get("error") or {}
        if isinstance(err, dict):
            event["fault_name"] = err.get("name", "")
            event["fault_message"] = _truncate(err.get("message", ""), 500)
        else:
            event["fault_name"] = ""
            event["fault_message"] = ""
        event["fault_context"] = (
            raw.get("errorContext") or raw.get("error_context") or ""
        )
        event["fault_recoverable"] = bool(raw.get("recoverable", False))

    elif kind == "permission-prompt":
        event["probe"] = raw.get("tool_name") or raw.get("toolName") or ""
        event["permission_reason"] = _truncate(str(raw.get("reason", "")), 500)

    elif kind == "signal":
        event["signal_type"] = (
            raw.get("notification_type")
            or raw.get("notificationType")
            or ""
        )
        event["signal_message"] = _truncate(str(raw.get("message", "")), 500)

    return event


def main(argv: list) -> int:
    if "--self-check" in argv:
        try:
            audit_dir().mkdir(parents=True, exist_ok=True)
            test = month_file().with_suffix(".jsonl.self-check")
            test.write_text("", encoding="utf-8")
            test.unlink(missing_ok=True)
        except Exception as e:
            _lib.log("self-check FAIL:", e)
            return 1
        _lib.log("self-check OK")
        return 0

    raw_input = sys.stdin.read()
    if not raw_input.strip():
        return 0

    try:
        raw = json.loads(raw_input)
    except json.JSONDecodeError:
        raw = {"raw": raw_input}

    try:
        event = normalize_event(raw)
        # Redaction pass: scrub the structured fields in-place, but
        # ONLY when the per-kind field is present. Per Common.md §4.5
        # the redaction itself is non-optional; per §5.2 the per-kind
        # fields are kind-scoped (stimulus only for `request`,
        # probe/probe_payload only for invocation-*, emission_marker
        # only for emission/subagent-emission). Materializing empty
        # strings for unrelated kinds pollutes downstream greps.
        if "stimulus" in event:
            event["stimulus"] = _lib.redact(event["stimulus"])
        if "probe_payload" in event:
            event["probe_payload"] = _lib.redact(event["probe_payload"])
        audit_dir().mkdir(parents=True, exist_ok=True)
        with open(month_file(), "a", encoding="utf-8") as f:
            f.write(json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n")
    except Exception as e:
        _lib.log("audit append failed:", e)
        # Audit failures must NEVER block — exit 0 anyway.
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
auditlogginggovernanceclaude-code
Author convergent-systems-key. Catalog data license CC-BY-4.0.