Skip to main content

Audit Logs and Decision Traces

Two complementary forensic records:

  • AuditLog — Postgres table; one row per CONTROLLED+ skill call. Durable, queryable, retained for AUDIT_RETENTION_DAYS (default 30).
  • DecisionTrace — Redis key per response, TTL ~24 h. Captures the full guard chain output and which skills were tried/blocked/allowed.

Together they answer: what did the agent do, and why?

AuditLog

CREATE TABLE audit_log (
id UUID PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL,
event_type VARCHAR(100),
source VARCHAR(50) DEFAULT '',
action VARCHAR(200) DEFAULT '',
input_summary TEXT DEFAULT '',
output_summary TEXT DEFAULT '',
user_id VARCHAR(50) DEFAULT '',
chat_id VARCHAR(50) DEFAULT '',
latency_ms INTEGER DEFAULT 0,
error TEXT,
metadata_json JSONB DEFAULT '{}'
);

CREATE INDEX ix_audit_log_chat_id_timestamp ON audit_log (chat_id, timestamp);

The composite index is created by ensure_indexes() at startup using CREATE INDEX CONCURRENTLY IF NOT EXISTS, so existing tables get index upgrades without downtime.

What gets logged

ActionTriggered by
skill.shellEvery shell skill call (with redacted command)
skill.self_improveEvery read/propose/apply/patch/install
skill.gmailEvery Gmail send/read/delete
skill.task_managerEvery task create/delete/trigger
skill.agent_managerEvery sub-agent CRUD
skill.python_execEvery python_exec invocation
skill.http_requestEvery http_request invocation
skill.remindersEvery reminder create/delete
agent.resetEvery Panic Reset
goal.created / .completed / .failed / .replannedGoal lifecycle
task.started / .completed / .failedTaskGraph step lifecycle

Capability-based logging

LevelLogged
SAFENo
MONITOREDNo
CONTROLLEDYes
RESTRICTEDYes
PRIVILEGEDYes

Secret redaction

All input and output summaries pass through redact() (utils/redaction.py) before writing.

Global patterns (every audit entry)

PatternExample
OpenAI keyssk-[a-zA-Z0-9]{20,}
Anthropic keyssk-ant-[a-zA-Z0-9-]{20,}
Google keysAIza[a-zA-Z0-9-_]{25,}
xAI keysxai-[a-zA-Z0-9]{20,}
HuggingFace tokenshf_[a-zA-Z0-9]{20,}
AWS access keysAKIA[A-Z0-9]{12,}
Stripe live keyssk_live_[a-zA-Z0-9]{24}
Slack tokensxox[bpoa]-[a-zA-Z0-9-]+
SendGrid keysSG\.[a-zA-Z0-9]{22}\.[a-zA-Z0-9]{43}
Bearer tokensBearer [a-zA-Z0-9+/=]{20,}
Password patternspassword[=:]\S+

Shell-specific redaction (v2.6)

The shell skill applies an additional command-level redaction via _redact_command() before the global redaction:

_REDACT_RE = re.compile(
r'(sk-[A-Za-z0-9\-_]{20,}|AIza[A-Za-z0-9\-_]{30,}|'
r'xai-[A-Za-z0-9\-_]{20,}|hf_[A-Za-z0-9]{20,}|'
r'(?:password|passwd|token|secret|key)\s*[=:]\s*\S+)',
re.IGNORECASE,
)

This catches secrets embedded directly in shell commands (e.g., curl -H "Authorization: Bearer sk-...").

Automatic retention

AuditRetentionJob runs every 6 h. It hard-deletes rows older than AUDIT_RETENTION_DAYS:

AUDIT_RETENTION_DAYS=30 # default

Bounded batch deletion to avoid table locks:

DELETE FROM audit_log
WHERE id IN (
SELECT id FROM audit_log
WHERE timestamp < (NOW() - INTERVAL '30 days')
LIMIT 5000
)

Logged as audit_retention.deleted count=N.

Querying

Dashboard

/audit — keyset paginator (cursor = "{timestamp}|{id}"). Filter by chat_id and date range.

SQL (direct)

SELECT timestamp, action, input_summary, output_summary, error
FROM audit_log
WHERE chat_id = '<your-chat-id>'
AND timestamp > NOW() - INTERVAL '7 days'
ORDER BY timestamp DESC
LIMIT 100;
-- Top 5 most-failing skills in the last 24h
SELECT action, COUNT(*) AS failures
FROM audit_log
WHERE error IS NOT NULL
AND timestamp > NOW() - INTERVAL '24 hours'
GROUP BY action
ORDER BY failures DESC
LIMIT 5;

Decision Trace

Every response — fast-path, Decision Layer route, or full LLM loop — emits a DecisionTrace:

DecisionTrace(
request_id = uuid.uuid4(),
path = "telegram" | "dashboard",
chat_id = "<chat_id>",
user_text_hash = sha1(user_text)[:12],
request_tier = "simple" | "normal" | "complex",
detected_language,
detected_intent,
allowed_skills,
blocked_skills,
guard_actions, # which guards fired and why
notes,
start_ts, end_ts, latency_ms,
)

Stored in Redis with TTL ~24 h. Surfaced at /traces.

Reading a trace

When you see surprising behavior, open /traces and find the request. Look at:

FieldWhat it tells you
pathtelegram or dashboard
request_tierSimple / normal / complex (drives the request budget)
detected_languageShould match user's language
detected_intentDid the classifier read the request correctly?
allowed_skillsWhat the LLM tried to call
blocked_skillsWhat the policy layer dropped, with reason
guard_actionsList of (guard_name, action, reason) tuples
notesAny free-form annotation from the pipeline
latency_msTotal response time

Common guard reasons

ReasonGuard
intent_gate.no_explicit_intentIntent gate dropped a side-effect skill
intent_gate.placeholder_subjectIntent gate detected a placeholder email subject
action_announcer.unverified_claimAnnouncer stripped a "I sent X" claim
enforce_schedule_honesty.user_textSchedule honesty appended a clock-time disclaimer
enforce_schedule_honesty.user_text_daypartSchedule honesty appended a daypart disclaimer
enforce_factual_grounding.appliedFactual grounding replaced a fabricated verdict
sanitize_markdown.linkMarkdown link collapsed to text (url)

Tamper-evidence

Decision traces and audit log entries can be deleted from Redis/Postgres by anyone with database access. There is no signing or external attestation by default.

For high-stakes deployments, consider:

  • OS-level filesystem audit (auditd, tripwire) on the host.
  • Mirroring AuditLog entries to an append-only external sink (S3 Object Lock, syslog with checksum chain, etc.).
  • Restricting Redis/Postgres access to the agent itself.

See also