AID-D-003.004: Tool-Call Sequence Anomaly Detection
Pillars: app
Lifecycle: operation
Scope boundary
Owns anomaly findings over the complete ordered stream of authenticated tool proposals and results, using an approved workflow graph or calibrated transition model to identify forbidden edges, missing stages, loops, pivots, and suspicious compositions.
AID-D-003.003: AID-D-003.004 detects risk created by tool-call order and composition; AID-D-003.003 monitors policy compliance and enforcement outcomes for individual actions. Sequence semantics require preserving event order and workflow state rather than judging each call independently.
AID-D-003.005: AID-D-003.004 focuses on ordered tool-call transitions and workflow graph violations; AID-D-003.005 tracks broader cross-turn intent drift and session security invariants. A session can drift without an anomalous tool sequence, and a suspicious sequence may occur before a clear intent shift is visible.
Implementation guidance
- AID-D-003.004-G001: Learn normal tool-call transitions, then alert on low-likelihood sequences.
AID-D-003.004-G001
Reusable transition-likelihood detector
Build and sign the transition baseline from an approved benign corpus outside this runtime. At detection time, consume a complete ordered session, score every adjacent transition, normalize by transition count, and keep unknown tools and missing sequence numbers visible.
from __future__ import annotations
# File: detection/tool_sequence_anomaly.py
import math
from dataclasses import dataclass
@dataclass(frozen=True)
class ToolEvent:
event_id: str
sequence: int
tool_name: str
def evaluate_tool_sequence(
session_id: str,
events: list[ToolEvent],
baseline: dict,
policy: dict,
*,
session_complete: bool,
) -> dict:
required_policy = {
"policy_version", "minimum_transitions",
"unknown_transition_probability",
"maximum_negative_log_likelihood_per_transition",
"forbidden_edges", "required_stages", "maximum_visits_per_tool",
}
if set(policy) != required_policy:
raise ValueError("tool-sequence policy schema mismatch")
if type(session_complete) is not bool:
raise ValueError("session completion must come from the authenticated event source")
if (type(policy["minimum_transitions"]) is not int or policy["minimum_transitions"] < 1
or not isinstance(policy["forbidden_edges"], list)
or any(not isinstance(edge, list) or len(edge) != 2 or
any(not isinstance(tool, str) or not tool for tool in edge)
for edge in policy["forbidden_edges"])
or not isinstance(policy["required_stages"], list)
or any(not isinstance(tool, str) or not tool for tool in policy["required_stages"])
or len(set(policy["required_stages"])) != len(policy["required_stages"])
or not isinstance(policy["maximum_visits_per_tool"], dict)
or any(not isinstance(tool, str) or not tool or type(bound) is not int or bound < 1
for tool, bound in policy["maximum_visits_per_tool"].items())):
raise ValueError("workflow policy is malformed")
threshold = float(policy["maximum_negative_log_likelihood_per_transition"])
if not math.isfinite(threshold) or threshold < 0:
raise ValueError("sequence likelihood threshold is invalid")
if len({event.event_id for event in events}) != len(events):
raise ValueError("duplicate tool event ID")
ordered = sorted(events, key=lambda event: event.sequence)
if [event.sequence for event in ordered] != list(range(len(ordered))):
return {
"control_status": "INSUFFICIENT_DATA",
"finding_status": "UNKNOWN",
"session_id": session_id,
"reason": "tool event sequence has a gap or nonzero start",
"policy_version": policy["policy_version"],
}
transition_count = max(0, len(ordered) - 1)
forbidden = {tuple(edge) for edge in policy["forbidden_edges"]}
violations = []
counts = {}
for event in ordered:
counts[event.tool_name] = counts.get(event.tool_name, 0) + 1
for left, right in zip(ordered, ordered[1:]):
if (left.tool_name, right.tool_name) in forbidden:
violations.append({"kind": "forbidden_edge", "event_ids": [left.event_id, right.event_id]})
for tool, maximum in policy["maximum_visits_per_tool"].items():
if counts.get(tool, 0) > maximum:
violations.append({"kind": "excessive_tool_loop", "tool": tool, "visits": counts[tool], "maximum": maximum})
if session_complete:
for tool in policy["required_stages"]:
if tool not in counts:
violations.append({"kind": "missing_required_stage", "tool": tool})
# Deterministic violations remain findings even before a statistical minimum.
sufficient = transition_count >= policy["minimum_transitions"]
unknown_probability = float(policy["unknown_transition_probability"])
if not 0.0 < unknown_probability < 1.0:
raise ValueError("unknown transition probability must be in (0,1)")
scored = []
total_negative_log = 0.0
for left, right in zip(ordered, ordered[1:]):
key = left.tool_name + " -> " + right.tool_name
probability = float(baseline["transition_probabilities"].get(key, unknown_probability))
if not 0.0 < probability <= 1.0:
raise ValueError("baseline transition probability outside (0,1]")
value = -math.log(probability)
total_negative_log += value
scored.append({
"from_tool": left.tool_name,
"to_tool": right.tool_name,
"probability": probability,
"negative_log_likelihood": round(value, 8),
"unknown_transition": key not in baseline["transition_probabilities"],
})
average = total_negative_log / transition_count if transition_count else None
finding = bool(violations) or (sufficient and average >= threshold)
return {
"schema_version": "aidefend.tool_sequence_finding.v1",
"control_status": "PASS" if finding or (sufficient and session_complete) else "INSUFFICIENT_DATA",
"finding_status": "FINDING" if finding else ("NO_FINDING" if sufficient and session_complete else "UNKNOWN"),
"workflow_violations": violations,
"session_complete": session_complete,
"statistical_population_sufficient": sufficient,
"ordered_event_ids": [event.event_id for event in ordered],
"session_id": session_id,
"baseline_version": baseline["baseline_version"],
"policy_version": policy["policy_version"],
"transition_count": transition_count,
"negative_log_likelihood_per_transition": round(average, 8) if average is not None else None,
"transitions": scored,
}Verification: Replay normal, rare-but-approved, unknown-tool, low-likelihood, duplicate, missing-sequence, concurrent-order-policy, and undersized sessions. Rebuild the ordered event population independently and compare every transition score.
Action: Score complete authenticated session sequences and emit a finding for low-likelihood behavior; do not force human approval or modify tool access from this detector.
Workflow checks: The signed policy now also carries forbidden_edges as two-tool arrays, required_stages as tool names, and maximum_visits_per_tool as positive integer limits selected for the approved workflow. Supply session_complete only from the authenticated terminal event and reconcile the event population first. Forbidden transitions and policy-bounded repeated visits can emit findings in short sessions; missing stages are evaluated only after completion. An incomplete clean prefix remains UNKNOWN. Replay forbidden-edge, omitted-stage and repeated-loop counterexamples as well as an approved short prefix. Anomaly findings do not prove which prompt caused the behavior.
Open-source tools
- pandas
- NumPy
- scikit-learn
- OpenTelemetry (ordered tool-call span capture; anomaly scoring remains detector-owned)
Commercial / hosted tools
- Splunk Enterprise Security
- Elastic Security
- Datadog LLM Observability
Threat mappings
MITRE ATLAS
- AML.T0053 AI Agent Tool Invocation
- AML.T0051 LLM Prompt Injection (anomalous tool sequences reveal injection-driven behavior)
- AML.T0051.001 LLM Prompt Injection: Indirect (indirect injections produce atypical tool chains)
- AML.T0051.002 LLM Prompt Injection: Triggered
- AML.T0086 Exfiltration via AI Agent Tool Invocation (unusual tool sequences signal exfiltration attempts)
- AML.T0051.000 LLM Prompt Injection: Direct
- AML.T0108 AI Agent
- AML.T0117 Autonomous Attack-Path Adaptation (off-graph tool sequences and high-risk pivots reveal adaptive attack-path replanning)
- AML.T0126 Automated Collection (repetitive retrieval loops outside the approved workflow graph)
MAESTRO
- Agent Tool Misuse (L7)
- Agent Goal Manipulation (L7)
- Compromised Agents (L7) (sequence anomalies reveal compromised agent behavior)
- Privilege Escalation (Cross-Layer) (anomalous tool sequences may indicate privilege escalation)
OWASP LLM Top 10 2026
- LLM03:2026 Excessive Agency
- LLM06:2026 Unbounded Consumption (sequence analysis detects recursive and non-progressing tool loops)
OWASP ML Top 10 2023
- N/A
OWASP Top 10 for Agentic Applications 2026
- ASI01:2026 Agent Goal Hijack (hijacked agents produce anomalous tool sequences)
- ASI02:2026 Tool Misuse and Exploitation
- ASI08:2026 Cascading Failures (anomalous sequences detect cascading tool misuse)
- ASI10:2026 Rogue Agents
- ASI03:2026 Identity and Privilege Abuse (tool sequence anomalies detect privilege abuse)
NIST Adversarial Machine Learning 2025
- NISTAML.018 Prompt Injection (detects injection via anomalous tool sequences)
- NISTAML.015 Indirect Prompt Injection
- NISTAML.039 Compromising connected resources (anomalous tool chains reveal lateral movement)
Cisco Integrated AI Security and Safety Framework
- AITech-2.1 Excessive Agency (forbidden workflow edges, missing required stages and bounded repeated tool visits reveal unauthorized action sequences)
Google Secure AI Framework 2.0 - Risks
- RA: Rogue Actions (anomalous tool sequences reveal rogue agent behavior)
- PIJ: Prompt Injection (injection-triggered tool chains appear anomalous)
Databricks AI Security Framework 3.0
- Agents - Core 13.2: Tool Misuse
- Agents - Core 13.6: Intent Breaking & Goal Manipulation
- Model Serving - Inference requests 9.13: Excessive agency
- Agents - Core 13.13: Rogue Agents in Multi-Agent Systems