Safeguard.sh Documentation Center

Audit Trail & Alerts

How Guard records every policy decision as an audit event, rolls those events up into aggregates for reporting, and the alert-rule schema behind webhook notifications when a condition is met.

Audit Trail & Alerts

Every request Guard evaluates — allow, deny, or monitor — is written to an append-only audit trail. Guard rolls those events up into hourly and daily aggregates for reporting and trend analysis. Guard also defines an alert-rule schema — thresholds on signals like a rising jailbreak score or an elevated ratio of blocked requests that are meant to fire a webhook — though as covered below, rule creation and evaluation aren't wired up yet. This page covers what's recorded, how to query it, and the alert-rule reference.

Every decision is an audit event

Each time Guard evaluates a request, it writes an audit_events row recording the decision and the signals behind it:

ColumnDescription
id, org_id, agent_id, server_id, proxy_url_idIdentifiers for the event and the agent/server/proxy URL it relates to
request_id, method, tool_nameWhich request this was and which tool it targeted
actionThe decision Guard made: allow, deny, or monitor
rule_id, rule_name, deny_reasonWhich policy rule matched, and why, when the action wasn't a plain allow
risk_score, jailbreak_score, anomaly_scoreThe scores Guard's analysis produced for this request
input_params, output_resultThe request's input parameters and the resulting output, stored as JSON
pii_detected, fields_redactedAny PII types detected and any fields redacted from the payload
latency_ms, status_code, error_messageTiming and outcome of the underlying call
created_atWhen the event occurred

Note that action is a three-way decision, not a simple allow/block: monitor covers requests Guard let through but flagged for visibility, distinct from an outright deny.

A denied request looks like this once it lands in the audit trail:

{
  "id": "b6e1c2b0-3e4a-4f1b-9c2d-1a2b3c4d5e6f",
  "org_id": "3fa0e6d2-...",
  "agent_id": "9c1e4a2b-...",
  "server_id": "7d2f5b3c-...",
  "request_id": "req_01HZX...",
  "method": "tools/call",
  "tool_name": "run_sql_query",
  "action": "deny",
  "rule_id": "2f8a...",
  "rule_name": "Block raw SQL from untrusted agents",
  "deny_reason": "input matched block_pattern rule",
  "risk_score": 0.83,
  "jailbreak_score": 0.12,
  "anomaly_score": 0.91,
  "pii_detected": ["email", "credit_card"],
  "fields_redacted": ["customer.email", "customer.card_number"],
  "latency_ms": 4,
  "status_code": 403,
  "created_at": "2026-07-07T14:02:31Z"
}

input_params and output_result are also present on every event (omitted above for brevity) — they carry the request's parameters and the tool's response as JSON, subject to whatever output filters your policy applies before the response reaches the audit trail.

The audit_events table is partitioned by month on created_at. A background worker task keeps roughly the current month plus three months of future partitions created ahead of time, and a separate daily job deletes events older than the retention window — 90 days by default (AUDIT_RETENTION_DAYS on a self-hosted Guard Proxy deployment) — so old data ages out automatically rather than accumulating indefinitely. This is a high-volume, append-only log designed to hold every decision Guard makes, not just the ones that get blocked.

Audit writes are asynchronous and non-blocking. Guard publishes each event onto a Redis Stream on the request's hot path and returns immediately; a background worker consumes that stream and batches events into Postgres — flushing every 100 events or every 1 second, whichever comes first, by default (AUDIT_BATCH_SIZE / AUDIT_BATCH_TIMEOUT). A slow or temporarily unavailable audit store does not add latency to, or block, the underlying policy decision — but it also means a just-made request can take up to roughly a second to show up when you query the trail immediately afterward.

Querying the audit trail

Two endpoints back the audit views, both under your Guard deployment's management API:

GET /api/v1/audit

Lists individual audit events for your organization, newest first.

Query paramRequiredNotes
actionNoExact match against allow, deny, or monitor. Omit to return all three.
methodNoExact match against the JSON-RPC method, e.g. tools/call.
from, toNoDate range filter on created_at. Either or both may be omitted — an omitted bound simply isn't applied.
pageNoZero-indexed page number. Defaults to 0.
page_sizeNoDefaults to 50, capped at 200.
curl "https://<your-guard-host>/api/v1/audit?action=deny&from=2026-07-01&to=2026-07-07&page=0&page_size=50" \
  -H "Authorization: Bearer <your-jwt>"
{
  "data": [
    { "id": "b6e1c2b0-...", "action": "deny", "tool_name": "run_sql_query", "risk_score": 0.83, "created_at": "2026-07-07T14:02:31Z", "...": "..." }
  ],
  "total": 214,
  "page": 0,
  "page_size": 50,
  "has_more": true
}

There's no agent_id or server_id query parameter today, even though both are recorded on every event — filter client-side on the returned events, or narrow by method/action/date range instead, if you need to isolate one agent or server.

GET /api/v1/audit/timeline

Returns event counts bucketed by hour or day, computed live over the raw events in the window you ask for — useful for trend charts without pulling every event.

Query paramRequiredNotes
periodNohour or day. Defaults to hour if omitted.
from, toYesUnlike GET /audit, both bounds are required — the underlying query uses them directly and errors if either is missing.
curl "https://<your-guard-host>/api/v1/audit/timeline?period=day&from=2026-07-01&to=2026-07-07" \
  -H "Authorization: Bearer <your-jwt>"
[
  {
    "period_start": "2026-07-06T00:00:00Z",
    "total_requests": 4820,
    "allowed_requests": 4510,
    "denied_requests": 260,
    "monitored_requests": 50,
    "avg_latency_ms": 38.4,
    "avg_risk_score": 0.11,
    "pii_detections": 37,
    "jailbreak_detections": 3
  }
]

GET /audit/timeline is unpaginated and returns one row per bucket in the window — a wide from/to range with period=hour can return a lot of rows. Prefer period=day for anything wider than a week or two.

Separately from the live GET /audit/timeline endpoint above, an hourly and a daily background job roll audit_events up into a persisted audit_aggregates table — one row per organization, per period (hour or day), per period_start:

ColumnDescription
total_requests, allowed_requests, denied_requests, monitored_requestsRequest counts by outcome for the period
avg_latency_ms, avg_risk_scoreAverages across the period
pii_detectionsCount of requests where at least one PII type was detected
jailbreak_detectionsCount of requests where jailbreak_score exceeded a fixed 0.5 cutoff in the rollup job — this is a different, hard-coded threshold from whatever jailbreak_score value you use in a policy rule or an alert rule
top_tools, top_agentsReserved fields for the most-active tools and agents in the period

(org_id, period, period_start) is unique, so re-running the rollup for a period upserts in place rather than duplicating rows.

top_tools and top_agents are present in the schema but are not populated by the aggregation job today — don't rely on them being non-empty.

audit_aggregates and GET /audit/timeline are two independent things today. The rollup job writes to audit_aggregates on a schedule, but GET /audit/timeline doesn't read from that table at all — it computes the same shape of summary live, directly from audit_events, for whatever window you pass in. In practice this means the timeline endpoint is always fresh (no rollup lag) but scans raw events for the requested range, and the persisted audit_aggregates table isn't currently exposed through any documented endpoint. If you need the exact rollup numbers rather than a live equivalent, query GET /audit/timeline with the same period and window — the values match, aside from the jailbreak_detections cutoff note above and the always-empty top_tools/top_agents fields, which the timeline response doesn't include at all.

Alert rules

This capability isn't reachable through the product yet. The alert_rules schema, trigger types, and message strings below exist in Guard's codebase, but as of this writing there's no /api/v1/... endpoint to create, list, or update an alert rule, and no running Guard process (proxy, API, or worker) evaluates live traffic against alert_rules rows or dispatches a notification when one matches — the trigger-matching and message-building logic isn't currently called from anywhere in the request path or a background job. Treat the rest of this section as a reference for the rule shape and message format rather than a feature you can configure and rely on today.

An alert rule watches a specific signal and fires when it crosses a threshold within a rolling window:

ColumnDescription
name, descriptionHow the rule identifies itself
trigger_typeWhat the rule watches: jailbreak_score, blocked_ratio, server_down, anomaly_score, or rate_limit
thresholdThe value that must be met or exceeded to trigger
window_secondsThe rolling window the threshold is evaluated over (defaults to 300 seconds)
severitylow, medium, high, or critical
is_activeWhether the rule is currently enabled
last_triggered_atWhen the rule last fired

Each trigger_type maps to a specific comparison, and produces a specific message string when it fires:

trigger_typeFires whenmessage sent in the alert
jailbreak_scoreA request's jailbreak score meets or exceeds thresholdJailbreak score 0.91 exceeded threshold 0.80
anomaly_scoreA request's anomaly score meets or exceeds thresholdAnomaly score 0.87 exceeded threshold 0.75
blocked_ratioThe ratio of denied requests meets or exceeds threshold, evaluated over the rule's window — not a raw count of denials from one specific agentBlocked request ratio 0.62 exceeded threshold 0.50
server_downThe connected MCP server is unreachableMCP server is unreachable
rate_limitA request's risk score (not literally a request-per-second rate) meets or exceeds thresholdAlert triggered: <rule name> — unlike the other trigger types, the message doesn't include the risk score or threshold value, so give a descriptive name to a rate_limit rule if you want the alert itself to convey what tripped it

blocked_ratio is the rule to use for something like "an agent is getting blocked repeatedly" — again, it's an org-wide ratio over the window, not scoped to one agent.

The rate_limit name is a bit of a misnomer: it doesn't watch requests-per-second directly, it compares against risk_score — a composite Guard computes per request as a weighted blend of jailbreak_score (60%) and anomaly_score (40%), capped at 1.0. If you want an alert that's purely about jailbreak or anomaly signal, use the jailbreak_score or anomaly_score trigger types instead; use rate_limit when you want to watch the blended score.

There isn't a dedicated "tool poisoning" trigger type, and tool poisoning findings don't appear in the audit trail at all. Tool poisoning is a separate, on-demand static analysis of a registered server's tool definitions (its name and description) — distinct from the per-request jailbreak_score/anomaly_score signals on audit_events, which are computed from request content, not tool metadata. See Tool-poisoning detection for how that check works.

How alerts are delivered

The intended flow is: when a rule's condition is met, Guard sends the alert to every active webhook configured for your organization that's subscribed to the alert.triggered event (or subscribed to all events with *) — see the note above on evaluation not being wired up yet. The payload is built like this:

{
  "event": "alert.triggered",
  "data": {
    "id": "...",
    "org_id": "...",
    "rule": { "name": "...", "trigger_type": "blocked_ratio", "severity": "high", "...": "..." },
    "severity": "high",
    "message": "Blocked request ratio 0.62 exceeded threshold 0.50",
    "triggered_at": "2026-07-07T00:00:00Z"
  }
}

Each delivery is signed the same way as any other Guard webhook — an HMAC-SHA256 signature over a timestamp-prefixed payload, sent as a sha256=<hex> header value — so you can verify it came from Guard before acting on it. See Webhooks for the full delivery, signing, and retry behavior.

Alerts, once evaluation is wired up, are delivered via webhook only — there's no separate email or in-app notification channel described anywhere in the codebase today. If you have no active webhook subscribed to alert.triggered (or *), a triggered rule would still update last_triggered_at but there'd be nowhere for the notification to go.

FAQ & Troubleshooting

Does every allowed request get logged, or only blocks? Every request Guard evaluates is logged, regardless of action. Filtering the audit trail to action=deny is a query you apply on top of a complete record, not a separate log.

I don't see any alert rule management in the API — how do I create one? There isn't a documented way to today. The alert_rules table and the trigger/message logic described above exist in Guard's codebase, but no /api/v1/... route creates, lists, or updates them, and no running Guard process evaluates traffic against them yet — so this section documents the rule shape and message format for when that lands, not a feature you can configure right now.

Can I alert on a single agent's block count instead of an org-wide ratio? The blocked_ratio trigger type evaluates a ratio over the rule's window, not a per-agent denial count. If you need to watch one agent specifically, scope your monitoring around that agent's activity in the audit trail rather than expecting the alert rule itself to isolate it.

Why don't top_tools / top_agents show up in audit_aggregates? Those columns exist in the schema for future use but aren't populated by the current aggregation job — use GET /audit with your own filters to identify top tools or agents in the meantime.

How long is audit data retained? 90 days by default (AUDIT_RETENTION_DAYS on a self-hosted deployment). A daily job deletes events older than the cutoff, and the underlying table is partitioned by month so the deletes stay cheap as data ages out.

I just made a request — why isn't it showing up in GET /audit yet? Audit writes are asynchronous: the event is published to a Redis Stream on the hot path and a background worker batches it into Postgres, flushing every 100 events or every 1 second by default. Query again after a second or two — this isn't a bug, it's the tradeoff that keeps audit logging from adding latency to the underlying request.

Can I filter GET /audit by agent_id or server_id? Not today. Both are recorded on every event, but the endpoint only exposes action, method, from/to, and pagination as query parameters. Filter client-side on the returned page, or narrow by method/date range if you need to zero in on one agent or server.

GET /audit/timeline returns an error — what am I missing? Unlike GET /audit, both from and to are required for the timeline endpoint; leaving either one out causes the underlying query to fail rather than defaulting to "everything." Always pass an explicit date range.

Is this the same "audit trail" as the one under Settings → Audit Logs in the portal? No — Portal Audit Trails logs administrative and workspace activity (who changed a policy, exported an SBOM, invited a user) for compliance purposes. This page covers Guard's separate, high-volume log of individual MCP request decisions (allow/deny/monitor). They're stored differently, queried through different endpoints, and retained on different schedules — don't expect one to show up in the other.

  • Policies — define the rules whose allow/deny/monitor decisions populate the audit trail.
  • Webhooks — configure delivery, signing, and retries for alert.triggered and other events.
  • Guard SDK — embeds Guard in your MCP server/agent process; also covers the separate tool-poisoning detection check, which doesn't write to the audit trail.
  • Guard Proxy Deployment — self-hosted deployment whose worker service runs the audit rollup, retention, and batch-writer jobs described on this page.
  • Servers & Agents — the agent_id and server_id inventory that every audit event and alert is scoped to.
  • Portal Audit Trails — the separate, workspace-activity audit log; not to be confused with the request-level trail on this page.

On this page