Logging (structured audit trail) — on by default
What it is: a structured, durable, machine-parseable record of what the agent did on every investigation — which Exabeam tools it called, how long they took, whether they succeeded, the gated action it took (which alert/case, to what disposition), and when the security guardrails fired. A good agent keeps an audit trail; socxen keeps one by default, so a production tenant can reconstruct a session or drive anomaly detection instead of relying on the free-form investigation report alone.
It runs inside the local MCP bridge — the one place that sees every Exabeam call — and is built on observra, an open-source agent-telemetry SDK. Events are written as newline-delimited JSON (one object per line) in the CIM-normalized observra schema.
observra 1.1 or newer is required (the bridge pins observra>=1.1,<2). socxen is not one of
observra's built-in frameworks — the "agent" here is a host agent (Claude Code or Codex) driving a skill
over an MCP bridge, so
there is no framework object to hook. Since 1.1 that is a first-class case: the shim emits through the
public observra.emit(), and the rotation bounds below pass through to the backend (clamped to
sane minimums). On older
observra neither is available and logging disables itself.
Exactly what is recorded
One event per tool call, plus a session marker at each end:
event_type |
When | Key fields |
|---|---|---|
mcp_session_start / mcp_session_end |
bridge process start / exit | session_id, host context; on start also the configuration attestation: telemetry_backend, telemetry_destination (resolved file path, or scheme + host of the endpoint), dry_run, plugin_version, gate_log |
tools_list |
the remote's tool definitions arrive (once per session) | tool_count, metadata_stripped / metadata_flagged (hidden code points the metadata screen removed or flagged in descriptions and schema text — counts only), metadata_screen_failed, odd_names (tool names carrying a hidden code point — spelled out as U+XXXX in the record, never altered in the definition), unclassified_tools (names no tier classifies; treated as writes), directive_tools (definitions whose text is instruction-shaped — surfaced, never altered; on the Exabeam MCP as shipped today every definition qualifies, so expect the full list and read tool_shas for change), surface_sha (a hash of the whole tool surface as the remote presented it this session; compare across sessions) and tool_shas (the same per tool, 12 hex) |
tool_start |
a tool call begins | tool_name |
tool_end with action.wildcardFieldsRedirected: true |
a search asked for every column (fields: ["*"]) and was answered with the column list instead of being sent — a workaround for the MCP server's schema text (#160), kept until the server is fixed |
tool_name |
tool_end |
a tool call succeeds | tool_name, duration_ms, + the fields below |
tool_error |
a tool call fails | tool_name, duration_ms, error_class, stage (neutralize = the write-side guardrail refused to forward, with guardrail_refused: true; remote = the upstream call failed; upstream_tool = the tool ran on the proxy and reported an error), and for a remote failure what actually failed: error_type_name, error_message (canonicalized, capped), http_status when there was one, is_retryable. A failed tools/list at startup is recorded under tool_name: tools/list. |
Every event also carries: framework: "mcp", agent_name: "socxen", skill_name: "soc-investigate",
ULID session_id / trace_id / span_id for correlation, a timestamp, and host context
(host, user, os, arch, library_version) for accountability.
On a tool_end, three extra kinds of field appear when relevant:
1. The decision record — for a gated write (update_alert, update_case, create_case,
create_case_notes), the safe identifier/enum fields of the action, namespaced action.*:
"action.alertId": "4471", "action.alertStatus": "closed", "action.disposition": "false_positive"
Captured fields: alertId, caseId, alertStatus, caseStatus, stage, priority, severity,
queue, disposition, useCases. This is what the agent decided, on which object — the audit-grade
core of the trail.
2. Guardrail firings — proof the defenses acted, correlated to the exact call:
"defang_formula": 1, "defang_link": 1 // output neutralizer defanged a formula / phishing link on a write
"hygiene_stripped": 3, "hygiene_classes": "U+200B,U+202E" // input canonicalizer stripped smuggling code points on a read
"hygiene_kept": 2, "hygiene_kept_classes": "U+200D,U+200F" // joiners / directional marks were present — kept verbatim, flagged here
"hygiene_screen_failed": true // screening threw on a block; it passed through raw (fail-open)
hygiene_kept is the only place that signal exists: the text is never altered and no marker is ever
written in-band (a marker in alert data would be forgeable). A right-to-left mark or a zero-width joiner in
a value is not proof of anything — Persian, Arabic and emoji use them legitimately — but on a homoglyph or
bidi-shaped investigation it is the field to query.
The gate's own record
The bundled Claude Code hook keeps a second, smaller log beside the telemetry: ~/.socxen/gate.jsonl
(SOCXEN_GATE_LOG=off disables it and says so on stderr; another path overrides; rotates at ~5 MB with
three backups). One line per decision:
{"ts": "2026-09-05T16:01:26+00:00", "tool": "mcp__plugin_socxen_exabeam__exabeam_update_alert",
"decision": "ask", "reason": "socxen gate: exabeam_update_alert dismisses or closes. It needs the analyst's explicit yes — ask, and wait.",
"target": {"alertId": "4471", "alertStatus": "DISMISSED"}}
target carries the same safe identifier and disposition fields the telemetry's action.* record uses,
and nothing else — so a refused attempt reads as tried to dismiss alert 4471 as dismissed, which is the
near-miss a SOC wants to see, while the note, description or reason text a payload can ride in never
enters the file. This is also the only record of an attempt the gate stopped before it reached the
bridge; the telemetry sees only calls that got that far.
What is deliberately NOT recorded (privacy by construction)
The log stores metadata about the agent's actions — never the raw evidence. Specifically excluded:
- Free-text field values —
note,alertDescription,alertName,supportingReason,closedReason,tags. The case-note text and alert prose never enter the log. - Tool arguments and results in general —
tool_args/tool_resultare alwaysnull. - The neutralized payloads themselves — a defanged formula or phishing URL is counted, never quoted.
The whole point of the guardrails is to neutralize hostile content; the audit log must not become a second copy of it. observra additionally applies its own PII redaction over everything above.
assigneeis intentionally not in the decision record. Operator identity is already captured once per session as host context (user/host), which is the accountability signal; the per-action assignee is omitted to avoid scattering it across every write.
Where it goes, and how it stays bounded
Default backend is a local, rotating JSON-lines file — no network egress:
| Setting | Env var | Default |
|---|---|---|
| Backend | SOCXEN_OBSERVRA |
jsonl (set off to disable) |
| File path | SOCXEN_OBSERVRA_PATH |
~/.socxen/telemetry.jsonl |
| Rotate at size | SOCXEN_OBSERVRA_MAX_BYTES |
10485760 (10 MB) |
| Backups kept | SOCXEN_OBSERVRA_BACKUPS |
5 |
| Webhook destination | SOCXEN_OBSERVRA_URL |
— (required for webhook) |
| OTLP endpoint | SOCXEN_OBSERVRA_ENDPOINT |
— (else OTEL_EXPORTER_OTLP_ENDPOINT / ..._LOGS_ENDPOINT, else http://localhost:4318) |
Whatever the backend, the bridge prints the resolved destination on stderr at startup (a path, or the
scheme + host of the endpoint — never a URL path or query, which can carry a token) and records the same on
the mcp_session_start event, so the trail itself attests where it was shipped. One disclosure cannot go
into the log: if telemetry disables itself (a misconfigured backend, a missing library) that fact is
printed to stderr only, because there is no longer a log to write it to.
So the log rotates (telemetry.jsonl → .1 → … → .5, oldest deleted) and is bounded to roughly
60 MB by default. It never grows without limit.
Other backends (opt-in via SOCXEN_OBSERVRA=): exabeam (routes telemetry back into Exabeam using the
bridge's own creds), otel / otel_log (OpenTelemetry), webhook. These make network calls, so jsonl
is the default. On first enable, the bridge prints one line to stderr naming the destination and how to
turn it off — disclosed, not silent.
Enabling a network backend is an egress decision you own. The default (
jsonl) never leaves the host.exabeam/otel/webhooksend events off-box — action metadata and safe IDs only, never case-note text, evidence, or payloads (the same privacy rules apply) — but you are choosing to route telemetry to that destination. Point them only at systems you control.
Finding and reading your log
By default it's at ~/.socxen/telemetry.jsonl (rotated backups are telemetry.jsonl.1 … .5). It's
one JSON object per line — read it with anything that speaks JSON lines:
tail -f ~/.socxen/telemetry.jsonl # watch events live
# the gated actions this session took (which alert, to what disposition):
jq -c 'select(.event_type=="tool_end" and (.data|has("action.disposition")))
| {tool: .tool_name, alert: .data."action.alertId", disp: .data."action.disposition"}' \
~/.socxen/telemetry.jsonl
# every time a guardrail fired:
jq -c 'select(.data.defang_formula or .data.defang_link or .data.hygiene_stripped)
| {tool: .tool_name, defang_formula: .data.defang_formula, defang_link: .data.defang_link,
hygiene: .data.hygiene_stripped}' ~/.socxen/telemetry.jsonl
Events from one investigation share a session_id, so you can reconstruct a run by grouping on it.
Fail-open
Logging is best-effort and can never break or slow an investigation. Two different failures, two different responses:
- Configuration-level — observra is unavailable or a backend is misconfigured. Telemetry disables itself (a one-line stderr note) and the bridge carries on exactly as if logging were off.
- A single event failing to emit — that one event is dropped and the trail stays on. A transient fault must not silently switch off a mandatory audit log for the rest of the session, so only a configuration-level failure disables. The drop is disclosed on stderr, never swallowed.
observra's own backend write errors are routed to stderr too, prefixed bridge: observra …, so a
failed write is visible rather than vanishing into a library logger. One gap worth knowing for an audit
trail: if the internal queue fills, observra drops the oldest event and records it only in a counter
(observra_events_dropped_total, readable via observra.get_stats()) at debug level — that drop is
counted, not announced. The security guardrails are independent and keep running throughout.
Turning it off
export SOCXEN_OBSERVRA=off
Off means off: no file, and observra is never imported.
Known limitation
The trail records the gated action and its disposition deterministically at the write sink, and in the
supported governance posture an
update_alert / update_case write only reaches the bridge after the human approves it — so the write
event is evidence the approval happened. It does not yet capture a distinct approver-identity event
(who clicked yes), because that lives in the host agent's approval layer, which the bridge cannot see —
Claude Code's permission rules, or Codex's tool-approval modes. An explicit approval event would be added
via a host-side post-tool hook feeding the same log.