socxen

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 valuesnote, 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_result are always null.
  • 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.

assignee is 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/webhook send 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.

Edit this page on GitHub ↗