Skip to content
Blog

Monitoring for Anomalous Tool Sequences: Detecting Exfiltration

Detect agent data exfiltration by tracing tool calls as OpenTelemetry spans and flagging anomalous tool sequences per trace.

Published on • October 18, 2026

AI Assistant

Individual tool calls rarely look wrong during exfiltration. Reading a file is legitimate. Base64-encoding a blob is a Tuesday. Posting to an external webhook is a normal integration. Exfiltration is defined by the sequence — read sensitive file, transform it, send it off-box — and by the time each action completes in isolation, nothing has tripped a per-call allowlist. The fix is to monitor at trace granularity: every agent run is a trace, every tool call is a span, and the ordered tool sequence within a trace is your detection surface. OpenTelemetry gives you the substrate — spans with parent-child structure, attributes, events, and a stable export pipeline — and the GenAI semantic conventions give agent tool calls a standard shape.

Model the Agent Run as a Trace

OpenTelemetry’s traces documentation defines a trace as the “path of a request through your application,” assembled from spans that share a trace_id and link to parents via parent_id. That maps onto an agent run with almost no impedance mismatch: the root span is the run, children are model calls, retrieval, and tool executions. Spans carry attributes (key-value metadata), events (timestamped points within a span), and a status (Unset, Error, Ok).

The GenAI conventions — which moved to their own repository but are still specified under the OTel umbrella — standardize the attributes that make agent traces machine-readable. Inference spans use gen_ai.operation.name (values include chat, embeddings, retrieval, invoke_agent, and — the one that matters here — execute_tool), gen_ai.provider.name, gen_ai.request.model, and error.type when the operation fails. The span name follows {gen_ai.operation.name} {gen_ai.request.model} for inference and tool spans.

Instrument a tool wrapper once, and every tool call in every agent becomes a structured span:

from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode

tracer = trace.get_tracer("agent.tools")

def instrumented_tool(name: str, fn):
    async def wrapper(*args, **kwargs):
        with tracer.start_as_current_span(
            f"execute_tool {name}",
            attributes={
                "gen_ai.operation.name": "execute_tool",
                "gen_ai.tool.name": name,
                "gen_ai.provider.name": "internal",
            },
        ) as span:
            # Record argument *shape*, not raw payloads — secrets don't belong in telemetry
            span.set_attribute("tool.arg_count", len(args) + len(kwargs))
            try:
                result = await fn(*args, **kwargs)
                return result
            except Exception as exc:
                span.set_status(Status(StatusCode.ERROR, str(exc)))
                span.record_exception(exc)
                raise
    return wrapper

Two details from the conventions worth respecting. First, spans should cover the logical operation — “if a transient issue happened and the request was retried automatically, the corresponding span SHOULD cover the duration of the logical operation with all retries,” which means your sequence detector sees the intent, not the retry noise. Second, content attributes are opt-in and flagged as likely sensitive; keep payloads out of spans by default and store evidence behind references.

Build the Sequence Detector

With tool spans flowing to your backend via an OTLP exporter (the span docs cover the TracerProvider/Exporter/Collector pipeline), detection becomes a per-trace reduction. Take every trace, order its execute_tool spans by start time, and reduce it to a tool-name sequence. Then attack it with two complementary engines.

Deterministic rules first. Exfiltration patterns are often enumerable. Score each trace against a small ruleset:

import re

SENSITIVE = re.compile(r"(\.env|id_rsa|credentials|secrets?|passwd|\.pem)", re.I)
SINKS = {"post_webhook", "send_email", "http_request", "upload_file", "publish_comment"}

def rule_score(sequence: list[dict]) -> list[str]:
    alerts, tools = [], [s["tool"] for s in sequence]
    args = " ".join(s.get("arg_summary", "") for s in sequence)

    if any(t in SINKS for t in tools) and SENSITIVE.search(args):
        alerts.append("SENSITIVE_CONTENT_TO_EXTERNAL_SINK")

    # read → transform → send is the canonical exfil shape
    for i in range(len(tools) - 2):
        window = tools[i:i + 3]
        if window[0].startswith("read_") and window[-1] in SINKS:
            if any(k in window[1] for k in ("encode", "compress", "transform", "extract")):
                alerts.append("READ_TRANSFORM_SEND_CHAIN")

    if len(set(tools)) == len(tools) > 6:  # many one-shot tools in one run
        alerts.append("HIGH_TOOL_CARDINALITY")
    return alerts

Baselines second. Keep a rolling distribution of per-agent sequences (as n-grams over tool names) per tenant. Anything below a frequency floor — or with unusual fan-out, unusual time-of-day, or an unseen sink for that agent — is an anomaly candidate even if no rule matches. This catches the slow-burn variant where an attacker uses only legitimate tools in a slightly wrong order over many runs.

Export, Don’t Just Log

A common failure is emitting spans into application logs where nobody queries them. Push them through an OTel Collector to a backend you alert on, and add metrics for the aggregate view:

# otel-collector config (excerpt)
exporters:
  otlp:
    endpoint: telemetry-backend:4317
processors:
  batch:
service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlp]

From the exported spans, derive metrics — traces per minute with READ_TRANSFORM_SEND_CHAIN, mean tools-per-run per agent, sink-call counts by destination — and alarm on rate-of-change, not just absolute counts. An exfiltration run looks like a change in shape: one trace where your support agent, which averages a handful of search_knowledge_base calls, suddenly does two read_file calls, an encode_base64, and an http_request to a domain it has never touched.

Close the Loop

Detection that pages nobody is decoration. Wire alerts to a response path that can act while the trace is fresh: kill the run (most agent runtimes expose a cancel handle on the run context), revoke the tool credential used by the sink, and attach the offending trace — tool names, timing, error status — to the incident. The trace_id is your join key across systems: the same id in the alert, the backend UI, and your case file.

Finally, red-team your own detector. Before shipping, replay captured traces through rule_score with an exfiltration sequence spliced in, and measure both the catch rate and the false-positive rate on benign runs. If your knowledge-base agent legitimately reads docs then posts to Slack, your READ_TRANSFORM_SEND_CHAIN rule needs an allowlist keyed by agent and destination — an allowlist you only discover by watching real traffic first.

Conclusion

Exfiltration is a sequence crime, so monitor sequences. Model each agent run as an OpenTelemetry trace, emit every tool call as a gen_ai.operation.name="execute_tool" span with standard attributes, reduce each trace to an ordered tool sequence, and score it with deterministic rules on top of per-tenant baselines. Export through the OTel pipeline to a backend you actually alarm on, and keep payloads out of telemetry by default. The instrumentation is a one-time wrapper; the payoff is that the boring, correct-looking actions of an attacker finally show up as a pattern you can see.