Spaces:
Running
Running
svkrishna commited on
Commit ·
7b7b417
1
Parent(s): 78b489c
🤖 Implement Story 4: Reflexive Core
Browse files- Add ReflexiveEngine for self-monitoring and corrective actions
- Implement PolicyMonitor, LedgerMonitor, and AnomalyDetector
- Add HaltAction, EscalateAction, MonitorAction, and AllowAction
- Create ActionFactory and ActionExecutor for action management
- Add HTTP API endpoints for risk simulation and engine status
- Integrate ReflexiveEngine into FastMCP server
- Add comprehensive test suite with 70 passing tests
- Support halt(), escalate(), monitor(), and allow() decisions
- Record reflexive events with cryptographic proof hashes
- Enable automatic risk assessment and corrective actions
Closes: Story 4 - Reflexive Core implementation
- examples/reflexive_example.py +191 -0
- src/fastmcp/reflexive/__init__.py +19 -0
- src/fastmcp/reflexive/actions.py +340 -0
- src/fastmcp/reflexive/engine.py +376 -0
- src/fastmcp/reflexive/monitor.py +356 -0
- src/fastmcp/server/reflexive_routes.py +385 -0
- src/fastmcp/server/server.py +38 -0
- tests/reflexive/__init__.py +1 -0
- tests/reflexive/test_actions.py +461 -0
- tests/reflexive/test_monitors.py +428 -0
- tests/reflexive/test_reflexive_engine.py +320 -0
- tests/reflexive/test_reflexive_http.py +325 -0
examples/reflexive_example.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Example usage of the Reflexive Core."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
from fastmcp import FastMCP
|
| 7 |
+
from fastmcp.reflexive import ReflexiveEngine, ActionContext, DecisionType, RiskLevel
|
| 8 |
+
from fastmcp.reflexive.monitor import PolicyMonitor, LedgerMonitor, AnomalyDetector
|
| 9 |
+
from fastmcp.reflexive.actions import ActionFactory, ActionExecutor
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
async def main():
|
| 13 |
+
"""Demonstrate reflexive core functionality."""
|
| 14 |
+
print("🚀 Reflexive Core Example")
|
| 15 |
+
print("=" * 50)
|
| 16 |
+
|
| 17 |
+
# Create a FastMCP server with reflexive core
|
| 18 |
+
server = FastMCP("ReflexiveExampleServer")
|
| 19 |
+
|
| 20 |
+
# Enable the reflexive core
|
| 21 |
+
reflexive_engine = server.enable_reflexive_core()
|
| 22 |
+
|
| 23 |
+
# Add monitors
|
| 24 |
+
policy_monitor = PolicyMonitor()
|
| 25 |
+
ledger_monitor = LedgerMonitor()
|
| 26 |
+
anomaly_detector = AnomalyDetector()
|
| 27 |
+
|
| 28 |
+
reflexive_engine.add_monitor(policy_monitor)
|
| 29 |
+
reflexive_engine.add_monitor(ledger_monitor)
|
| 30 |
+
reflexive_engine.add_monitor(anomaly_detector)
|
| 31 |
+
|
| 32 |
+
print(f"✅ Reflexive core enabled with {len(reflexive_engine.monitors)} monitors")
|
| 33 |
+
|
| 34 |
+
# Start the reflexive engine
|
| 35 |
+
await reflexive_engine.start()
|
| 36 |
+
print("✅ Reflexive engine started")
|
| 37 |
+
|
| 38 |
+
# Create action executor
|
| 39 |
+
action_executor = ActionExecutor()
|
| 40 |
+
|
| 41 |
+
# Example 1: Normal action (should be allowed)
|
| 42 |
+
print("\n📋 Example 1: Normal Action")
|
| 43 |
+
print("-" * 30)
|
| 44 |
+
|
| 45 |
+
normal_action = ActionContext(
|
| 46 |
+
action_id="normal_action_001",
|
| 47 |
+
actor_id="authorized_user",
|
| 48 |
+
action_type="data_read",
|
| 49 |
+
resource_id="public_data",
|
| 50 |
+
metadata={"authorized": True}
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
decision = await reflexive_engine._evaluate_action(normal_action)
|
| 54 |
+
print(f"Decision: {decision.decision_type}")
|
| 55 |
+
print(f"Risk Level: {decision.risk_level}")
|
| 56 |
+
print(f"Reason: {decision.reason}")
|
| 57 |
+
|
| 58 |
+
# Execute the action
|
| 59 |
+
action = ActionFactory.create_action(decision)
|
| 60 |
+
result = await action_executor.execute_action(action)
|
| 61 |
+
print(f"Action Result: {action.get_action_type()} - {result.get('allowed', 'N/A')}")
|
| 62 |
+
|
| 63 |
+
# Example 2: Policy violation (should be halted)
|
| 64 |
+
print("\n🚨 Example 2: Policy Violation")
|
| 65 |
+
print("-" * 30)
|
| 66 |
+
|
| 67 |
+
violation_action = ActionContext(
|
| 68 |
+
action_id="violation_action_002",
|
| 69 |
+
actor_id="guest_user",
|
| 70 |
+
action_type="admin_access",
|
| 71 |
+
resource_id="admin_panel",
|
| 72 |
+
metadata={"authorized": False}
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
decision = await reflexive_engine._evaluate_action(violation_action)
|
| 76 |
+
print(f"Decision: {decision.decision_type}")
|
| 77 |
+
print(f"Risk Level: {decision.risk_level}")
|
| 78 |
+
print(f"Reason: {decision.reason}")
|
| 79 |
+
|
| 80 |
+
# Execute the action
|
| 81 |
+
action = ActionFactory.create_action(decision)
|
| 82 |
+
result = await action_executor.execute_action(action)
|
| 83 |
+
print(f"Action Result: {action.get_action_type()}")
|
| 84 |
+
print(f"Halted Operations: {result.get('halted_operations', [])}")
|
| 85 |
+
|
| 86 |
+
# Example 3: Anomaly detection (should be escalated)
|
| 87 |
+
print("\n⚠️ Example 3: Anomaly Detection")
|
| 88 |
+
print("-" * 30)
|
| 89 |
+
|
| 90 |
+
# Simulate multiple rapid actions to trigger anomaly
|
| 91 |
+
for i in range(25):
|
| 92 |
+
rapid_action = ActionContext(
|
| 93 |
+
action_id=f"rapid_action_{i:03d}",
|
| 94 |
+
actor_id="suspicious_user",
|
| 95 |
+
action_type="api_call",
|
| 96 |
+
resource_id="api_endpoint"
|
| 97 |
+
)
|
| 98 |
+
anomaly_detector._update_patterns(rapid_action)
|
| 99 |
+
|
| 100 |
+
# Now test the anomaly detection
|
| 101 |
+
anomaly_action = ActionContext(
|
| 102 |
+
action_id="anomaly_action_003",
|
| 103 |
+
actor_id="suspicious_user",
|
| 104 |
+
action_type="api_call",
|
| 105 |
+
resource_id="api_endpoint"
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
decision = await reflexive_engine._evaluate_action(anomaly_action)
|
| 109 |
+
print(f"Decision: {decision.decision_type}")
|
| 110 |
+
print(f"Risk Level: {decision.risk_level}")
|
| 111 |
+
print(f"Reason: {decision.reason}")
|
| 112 |
+
|
| 113 |
+
# Execute the action
|
| 114 |
+
action = ActionFactory.create_action(decision)
|
| 115 |
+
result = await action_executor.execute_action(action)
|
| 116 |
+
print(f"Action Result: {action.get_action_type()}")
|
| 117 |
+
print(f"Escalation Target: {result.get('escalation_target', 'N/A')}")
|
| 118 |
+
|
| 119 |
+
# Example 4: Risk simulation
|
| 120 |
+
print("\n🎯 Example 4: Risk Simulation")
|
| 121 |
+
print("-" * 30)
|
| 122 |
+
|
| 123 |
+
risk_scenario = {
|
| 124 |
+
"action_context": {
|
| 125 |
+
"action_id": "simulation_action",
|
| 126 |
+
"actor_id": "test_actor",
|
| 127 |
+
"action_type": "privilege_escalation",
|
| 128 |
+
"resource_id": "root_access",
|
| 129 |
+
"metadata": {"escalation_attempt": True}
|
| 130 |
+
},
|
| 131 |
+
"monitors": [
|
| 132 |
+
lambda ctx: {
|
| 133 |
+
"type": "violation",
|
| 134 |
+
"severity": "critical",
|
| 135 |
+
"violations": [{
|
| 136 |
+
"rule": "privilege_escalation",
|
| 137 |
+
"message": "Unauthorized privilege escalation attempt",
|
| 138 |
+
"severity": "critical"
|
| 139 |
+
}]
|
| 140 |
+
}
|
| 141 |
+
]
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
decision = await reflexive_engine.simulate_risk(risk_scenario)
|
| 145 |
+
print(f"Simulation Decision: {decision.decision_type}")
|
| 146 |
+
print(f"Simulation Risk Level: {decision.risk_level}")
|
| 147 |
+
print(f"Simulation Reason: {decision.reason}")
|
| 148 |
+
|
| 149 |
+
# Example 5: Monitor statistics
|
| 150 |
+
print("\n📊 Example 5: Monitor Statistics")
|
| 151 |
+
print("-" * 30)
|
| 152 |
+
|
| 153 |
+
policy_stats = policy_monitor.get_violation_stats()
|
| 154 |
+
ledger_stats = ledger_monitor.get_integrity_stats()
|
| 155 |
+
anomaly_stats = anomaly_detector.get_anomaly_stats()
|
| 156 |
+
|
| 157 |
+
print(f"Policy Monitor - Total Violations: {policy_stats['total_violations']}")
|
| 158 |
+
print(f"Policy Monitor - Actor Violations: {policy_stats['actor_violations']}")
|
| 159 |
+
print(f"Ledger Monitor - Total Checks: {ledger_stats['total_checks']}")
|
| 160 |
+
print(f"Anomaly Detector - Tracked Actors: {anomaly_stats['tracked_actors']}")
|
| 161 |
+
|
| 162 |
+
# Example 6: Engine status
|
| 163 |
+
print("\n🔧 Example 6: Engine Status")
|
| 164 |
+
print("-" * 30)
|
| 165 |
+
|
| 166 |
+
status = reflexive_engine.get_engine_status()
|
| 167 |
+
print(f"Engine Running: {status['is_running']}")
|
| 168 |
+
print(f"Monitor Count: {status['monitor_count']}")
|
| 169 |
+
print(f"Queue Size: {status['queue_size']}")
|
| 170 |
+
print(f"Decision Handlers: {status['decision_handlers']}")
|
| 171 |
+
|
| 172 |
+
# Example 7: Action execution statistics
|
| 173 |
+
print("\n📈 Example 7: Action Execution Statistics")
|
| 174 |
+
print("-" * 30)
|
| 175 |
+
|
| 176 |
+
exec_stats = action_executor.get_execution_stats()
|
| 177 |
+
print(f"Total Actions: {exec_stats['total_actions']}")
|
| 178 |
+
print(f"Completed Actions: {exec_stats['completed_actions']}")
|
| 179 |
+
print(f"Failed Actions: {exec_stats['failed_actions']}")
|
| 180 |
+
print(f"Success Rate: {exec_stats['success_rate']:.2%}")
|
| 181 |
+
|
| 182 |
+
# Stop the reflexive engine
|
| 183 |
+
await reflexive_engine.stop()
|
| 184 |
+
print("\n✅ Reflexive engine stopped")
|
| 185 |
+
|
| 186 |
+
print("\n🎉 Reflexive Core Example Complete!")
|
| 187 |
+
print("=" * 50)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
if __name__ == "__main__":
|
| 191 |
+
asyncio.run(main())
|
src/fastmcp/reflexive/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastMCP Reflexive Core - Self-monitoring and corrective action system."""
|
| 2 |
+
|
| 3 |
+
from .engine import ReflexiveEngine, ReflexiveDecision, DecisionType, RiskLevel, ActionContext
|
| 4 |
+
from .monitor import PolicyMonitor, LedgerMonitor, AnomalyDetector
|
| 5 |
+
from .actions import HaltAction, EscalateAction, ReflexiveAction
|
| 6 |
+
|
| 7 |
+
__all__ = [
|
| 8 |
+
"ReflexiveEngine",
|
| 9 |
+
"ReflexiveDecision",
|
| 10 |
+
"DecisionType",
|
| 11 |
+
"RiskLevel",
|
| 12 |
+
"ActionContext",
|
| 13 |
+
"PolicyMonitor",
|
| 14 |
+
"LedgerMonitor",
|
| 15 |
+
"AnomalyDetector",
|
| 16 |
+
"HaltAction",
|
| 17 |
+
"EscalateAction",
|
| 18 |
+
"ReflexiveAction"
|
| 19 |
+
]
|
src/fastmcp/reflexive/actions.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Action components for the reflexive core."""
|
| 2 |
+
|
| 3 |
+
from abc import ABC, abstractmethod
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from typing import Any, Dict, List, Optional
|
| 6 |
+
from uuid import UUID, uuid4
|
| 7 |
+
|
| 8 |
+
from pydantic import BaseModel, Field
|
| 9 |
+
|
| 10 |
+
from .engine import ReflexiveDecision, ActionContext
|
| 11 |
+
from fastmcp.utilities.logging import get_logger
|
| 12 |
+
|
| 13 |
+
logger = get_logger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ReflexiveAction(BaseModel, ABC):
|
| 17 |
+
"""Base class for reflexive actions."""
|
| 18 |
+
|
| 19 |
+
action_id: UUID = Field(default_factory=uuid4, description="Unique action identifier")
|
| 20 |
+
decision: ReflexiveDecision = Field(..., description="The decision that triggered this action")
|
| 21 |
+
timestamp: datetime = Field(default_factory=datetime.utcnow, description="When the action was created")
|
| 22 |
+
status: str = Field(default="pending", description="Action status")
|
| 23 |
+
result: Optional[Dict[str, Any]] = Field(default=None, description="Action result")
|
| 24 |
+
|
| 25 |
+
@abstractmethod
|
| 26 |
+
async def execute(self) -> Dict[str, Any]:
|
| 27 |
+
"""Execute the reflexive action."""
|
| 28 |
+
pass
|
| 29 |
+
|
| 30 |
+
@abstractmethod
|
| 31 |
+
def get_action_type(self) -> str:
|
| 32 |
+
"""Get the type of this action."""
|
| 33 |
+
pass
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class HaltAction(ReflexiveAction):
|
| 37 |
+
"""Action to halt unsafe execution."""
|
| 38 |
+
|
| 39 |
+
halt_reason: str = Field(..., description="Reason for halting")
|
| 40 |
+
halt_level: str = Field(default="immediate", description="Level of halt (immediate, graceful, etc.)")
|
| 41 |
+
affected_operations: List[str] = Field(default_factory=list, description="Operations affected by the halt")
|
| 42 |
+
|
| 43 |
+
async def execute(self) -> Dict[str, Any]:
|
| 44 |
+
"""Execute the halt action."""
|
| 45 |
+
try:
|
| 46 |
+
self.status = "executing"
|
| 47 |
+
|
| 48 |
+
# Log the halt
|
| 49 |
+
logger.critical(f"HALTING OPERATIONS: {self.halt_reason}")
|
| 50 |
+
logger.critical(f"Affected operations: {self.affected_operations}")
|
| 51 |
+
logger.critical(f"Decision ID: {self.decision.decision_id}")
|
| 52 |
+
|
| 53 |
+
# In a real implementation, this would:
|
| 54 |
+
# 1. Stop the current operation
|
| 55 |
+
# 2. Cancel pending operations
|
| 56 |
+
# 3. Notify relevant systems
|
| 57 |
+
# 4. Update system state
|
| 58 |
+
|
| 59 |
+
# Simulate halt execution
|
| 60 |
+
halt_result = {
|
| 61 |
+
"halted_operations": self.affected_operations,
|
| 62 |
+
"halt_timestamp": self.timestamp.isoformat(),
|
| 63 |
+
"halt_reason": self.halt_reason,
|
| 64 |
+
"halt_level": self.halt_level,
|
| 65 |
+
"decision_id": str(self.decision.decision_id)
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
self.result = halt_result
|
| 69 |
+
self.status = "completed"
|
| 70 |
+
|
| 71 |
+
logger.info(f"Halt action completed: {self.action_id}")
|
| 72 |
+
return halt_result
|
| 73 |
+
|
| 74 |
+
except Exception as e:
|
| 75 |
+
self.status = "failed"
|
| 76 |
+
self.result = {"error": str(e)}
|
| 77 |
+
logger.error(f"Halt action failed: {e}")
|
| 78 |
+
raise
|
| 79 |
+
|
| 80 |
+
def get_action_type(self) -> str:
|
| 81 |
+
"""Get the type of this action."""
|
| 82 |
+
return "halt"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class EscalateAction(ReflexiveAction):
|
| 86 |
+
"""Action to escalate an issue to higher authority."""
|
| 87 |
+
|
| 88 |
+
escalation_target: str = Field(..., description="Target role/entity for escalation")
|
| 89 |
+
escalation_priority: str = Field(default="normal", description="Priority of the escalation")
|
| 90 |
+
escalation_context: Dict[str, Any] = Field(default_factory=dict, description="Additional context for escalation")
|
| 91 |
+
notification_channels: List[str] = Field(default_factory=list, description="Channels to use for notification")
|
| 92 |
+
|
| 93 |
+
async def execute(self) -> Dict[str, Any]:
|
| 94 |
+
"""Execute the escalation action."""
|
| 95 |
+
try:
|
| 96 |
+
self.status = "executing"
|
| 97 |
+
|
| 98 |
+
# Log the escalation
|
| 99 |
+
logger.warning(f"ESCALATING TO {self.escalation_target}: {self.decision.reason}")
|
| 100 |
+
logger.warning(f"Priority: {self.escalation_priority}")
|
| 101 |
+
logger.warning(f"Decision ID: {self.decision.decision_id}")
|
| 102 |
+
|
| 103 |
+
# In a real implementation, this would:
|
| 104 |
+
# 1. Create escalation ticket/alert
|
| 105 |
+
# 2. Send notifications via configured channels
|
| 106 |
+
# 3. Update escalation tracking
|
| 107 |
+
# 4. Set up monitoring for response
|
| 108 |
+
|
| 109 |
+
# Simulate escalation execution
|
| 110 |
+
escalation_result = {
|
| 111 |
+
"escalation_target": self.escalation_target,
|
| 112 |
+
"escalation_priority": self.escalation_priority,
|
| 113 |
+
"escalation_timestamp": self.timestamp.isoformat(),
|
| 114 |
+
"escalation_context": self.escalation_context,
|
| 115 |
+
"notification_channels": self.notification_channels,
|
| 116 |
+
"decision_id": str(self.decision.decision_id),
|
| 117 |
+
"action_context": self.decision.action_context.model_dump(mode='json')
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
self.result = escalation_result
|
| 121 |
+
self.status = "completed"
|
| 122 |
+
|
| 123 |
+
logger.info(f"Escalation action completed: {self.action_id}")
|
| 124 |
+
return escalation_result
|
| 125 |
+
|
| 126 |
+
except Exception as e:
|
| 127 |
+
self.status = "failed"
|
| 128 |
+
self.result = {"error": str(e)}
|
| 129 |
+
logger.error(f"Escalation action failed: {e}")
|
| 130 |
+
raise
|
| 131 |
+
|
| 132 |
+
def get_action_type(self) -> str:
|
| 133 |
+
"""Get the type of this action."""
|
| 134 |
+
return "escalate"
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class MonitorAction(ReflexiveAction):
|
| 138 |
+
"""Action to increase monitoring for an operation."""
|
| 139 |
+
|
| 140 |
+
monitoring_level: str = Field(default="enhanced", description="Level of monitoring to apply")
|
| 141 |
+
monitoring_duration: int = Field(default=3600, description="Duration of enhanced monitoring in seconds")
|
| 142 |
+
monitoring_scope: List[str] = Field(default_factory=list, description="Scope of monitoring")
|
| 143 |
+
|
| 144 |
+
async def execute(self) -> Dict[str, Any]:
|
| 145 |
+
"""Execute the monitoring action."""
|
| 146 |
+
try:
|
| 147 |
+
self.status = "executing"
|
| 148 |
+
|
| 149 |
+
# Log the monitoring increase
|
| 150 |
+
logger.info(f"ENHANCING MONITORING: {self.decision.reason}")
|
| 151 |
+
logger.info(f"Monitoring level: {self.monitoring_level}")
|
| 152 |
+
logger.info(f"Duration: {self.monitoring_duration} seconds")
|
| 153 |
+
|
| 154 |
+
# In a real implementation, this would:
|
| 155 |
+
# 1. Increase logging verbosity
|
| 156 |
+
# 2. Add additional monitoring points
|
| 157 |
+
# 3. Set up alerts for specific conditions
|
| 158 |
+
# 4. Schedule monitoring reduction after duration
|
| 159 |
+
|
| 160 |
+
# Simulate monitoring execution
|
| 161 |
+
monitoring_result = {
|
| 162 |
+
"monitoring_level": self.monitoring_level,
|
| 163 |
+
"monitoring_duration": self.monitoring_duration,
|
| 164 |
+
"monitoring_scope": self.monitoring_scope,
|
| 165 |
+
"monitoring_timestamp": self.timestamp.isoformat(),
|
| 166 |
+
"decision_id": str(self.decision.decision_id),
|
| 167 |
+
"action_context": self.decision.action_context.model_dump(mode='json')
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
self.result = monitoring_result
|
| 171 |
+
self.status = "completed"
|
| 172 |
+
|
| 173 |
+
logger.info(f"Monitoring action completed: {self.action_id}")
|
| 174 |
+
return monitoring_result
|
| 175 |
+
|
| 176 |
+
except Exception as e:
|
| 177 |
+
self.status = "failed"
|
| 178 |
+
self.result = {"error": str(e)}
|
| 179 |
+
logger.error(f"Monitoring action failed: {e}")
|
| 180 |
+
raise
|
| 181 |
+
|
| 182 |
+
def get_action_type(self) -> str:
|
| 183 |
+
"""Get the type of this action."""
|
| 184 |
+
return "monitor"
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
class AllowAction(ReflexiveAction):
|
| 188 |
+
"""Action to allow an operation to proceed."""
|
| 189 |
+
|
| 190 |
+
allow_conditions: List[str] = Field(default_factory=list, description="Conditions under which the action is allowed")
|
| 191 |
+
allow_restrictions: List[str] = Field(default_factory=list, description="Restrictions that still apply")
|
| 192 |
+
|
| 193 |
+
async def execute(self) -> Dict[str, Any]:
|
| 194 |
+
"""Execute the allow action."""
|
| 195 |
+
try:
|
| 196 |
+
self.status = "executing"
|
| 197 |
+
|
| 198 |
+
# Log the allowance
|
| 199 |
+
logger.debug(f"ALLOWING OPERATION: {self.decision.action_context.action_id}")
|
| 200 |
+
logger.debug(f"Reason: {self.decision.reason}")
|
| 201 |
+
|
| 202 |
+
# In a real implementation, this would:
|
| 203 |
+
# 1. Remove any temporary restrictions
|
| 204 |
+
# 2. Log the decision for audit
|
| 205 |
+
# 3. Continue normal operation flow
|
| 206 |
+
|
| 207 |
+
# Simulate allow execution
|
| 208 |
+
allow_result = {
|
| 209 |
+
"allowed": True,
|
| 210 |
+
"allow_timestamp": self.timestamp.isoformat(),
|
| 211 |
+
"allow_conditions": self.allow_conditions,
|
| 212 |
+
"allow_restrictions": self.allow_restrictions,
|
| 213 |
+
"decision_id": str(self.decision.decision_id),
|
| 214 |
+
"action_context": self.decision.action_context.model_dump(mode='json')
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
self.result = allow_result
|
| 218 |
+
self.status = "completed"
|
| 219 |
+
|
| 220 |
+
logger.debug(f"Allow action completed: {self.action_id}")
|
| 221 |
+
return allow_result
|
| 222 |
+
|
| 223 |
+
except Exception as e:
|
| 224 |
+
self.status = "failed"
|
| 225 |
+
self.result = {"error": str(e)}
|
| 226 |
+
logger.error(f"Allow action failed: {e}")
|
| 227 |
+
raise
|
| 228 |
+
|
| 229 |
+
def get_action_type(self) -> str:
|
| 230 |
+
"""Get the type of this action."""
|
| 231 |
+
return "allow"
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
class ActionFactory:
|
| 235 |
+
"""Factory for creating reflexive actions."""
|
| 236 |
+
|
| 237 |
+
@staticmethod
|
| 238 |
+
def create_action(decision: ReflexiveDecision, **kwargs) -> ReflexiveAction:
|
| 239 |
+
"""Create a reflexive action based on a decision."""
|
| 240 |
+
decision_type = decision.decision_type.value if hasattr(decision.decision_type, 'value') else str(decision.decision_type)
|
| 241 |
+
|
| 242 |
+
if decision_type == "halt":
|
| 243 |
+
return HaltAction(
|
| 244 |
+
decision=decision,
|
| 245 |
+
halt_reason=decision.reason,
|
| 246 |
+
halt_level=kwargs.get("halt_level", "immediate"),
|
| 247 |
+
affected_operations=kwargs.get("affected_operations", [decision.action_context.action_id])
|
| 248 |
+
)
|
| 249 |
+
elif decision_type == "escalate":
|
| 250 |
+
return EscalateAction(
|
| 251 |
+
decision=decision,
|
| 252 |
+
escalation_target=decision.escalated_to or "default_admin",
|
| 253 |
+
escalation_priority=kwargs.get("escalation_priority", "normal"),
|
| 254 |
+
escalation_context=kwargs.get("escalation_context", {}),
|
| 255 |
+
notification_channels=kwargs.get("notification_channels", ["email", "slack"])
|
| 256 |
+
)
|
| 257 |
+
elif decision_type == "monitor":
|
| 258 |
+
return MonitorAction(
|
| 259 |
+
decision=decision,
|
| 260 |
+
monitoring_level=kwargs.get("monitoring_level", "enhanced"),
|
| 261 |
+
monitoring_duration=kwargs.get("monitoring_duration", 3600),
|
| 262 |
+
monitoring_scope=kwargs.get("monitoring_scope", [decision.action_context.actor_id])
|
| 263 |
+
)
|
| 264 |
+
elif decision_type == "allow":
|
| 265 |
+
return AllowAction(
|
| 266 |
+
decision=decision,
|
| 267 |
+
allow_conditions=kwargs.get("allow_conditions", []),
|
| 268 |
+
allow_restrictions=kwargs.get("allow_restrictions", [])
|
| 269 |
+
)
|
| 270 |
+
else:
|
| 271 |
+
raise ValueError(f"Unknown decision type: {decision.decision_type}")
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
class ActionExecutor:
|
| 275 |
+
"""Executor for reflexive actions."""
|
| 276 |
+
|
| 277 |
+
def __init__(self):
|
| 278 |
+
"""Initialize the action executor."""
|
| 279 |
+
self.execution_history = []
|
| 280 |
+
self.active_actions = {}
|
| 281 |
+
|
| 282 |
+
async def execute_action(self, action: ReflexiveAction) -> Dict[str, Any]:
|
| 283 |
+
"""Execute a reflexive action."""
|
| 284 |
+
try:
|
| 285 |
+
# Record the action
|
| 286 |
+
self.active_actions[str(action.action_id)] = action
|
| 287 |
+
self.execution_history.append({
|
| 288 |
+
"action_id": str(action.action_id),
|
| 289 |
+
"action_type": action.get_action_type(),
|
| 290 |
+
"decision_id": str(action.decision.decision_id),
|
| 291 |
+
"start_time": action.timestamp.isoformat(),
|
| 292 |
+
"status": action.status
|
| 293 |
+
})
|
| 294 |
+
|
| 295 |
+
# Execute the action
|
| 296 |
+
result = await action.execute()
|
| 297 |
+
|
| 298 |
+
# Update history
|
| 299 |
+
for record in self.execution_history:
|
| 300 |
+
if record["action_id"] == str(action.action_id):
|
| 301 |
+
record["end_time"] = datetime.utcnow().isoformat()
|
| 302 |
+
record["status"] = action.status
|
| 303 |
+
record["result"] = result
|
| 304 |
+
break
|
| 305 |
+
|
| 306 |
+
# Remove from active actions
|
| 307 |
+
if str(action.action_id) in self.active_actions:
|
| 308 |
+
del self.active_actions[str(action.action_id)]
|
| 309 |
+
|
| 310 |
+
return result
|
| 311 |
+
|
| 312 |
+
except Exception as e:
|
| 313 |
+
# Update history with error
|
| 314 |
+
for record in self.execution_history:
|
| 315 |
+
if record["action_id"] == str(action.action_id):
|
| 316 |
+
record["end_time"] = datetime.utcnow().isoformat()
|
| 317 |
+
record["status"] = "failed"
|
| 318 |
+
record["error"] = str(e)
|
| 319 |
+
break
|
| 320 |
+
|
| 321 |
+
# Remove from active actions
|
| 322 |
+
if str(action.action_id) in self.active_actions:
|
| 323 |
+
del self.active_actions[str(action.action_id)]
|
| 324 |
+
|
| 325 |
+
raise
|
| 326 |
+
|
| 327 |
+
def get_execution_stats(self) -> Dict[str, Any]:
|
| 328 |
+
"""Get execution statistics."""
|
| 329 |
+
total_actions = len(self.execution_history)
|
| 330 |
+
completed_actions = len([a for a in self.execution_history if a.get("status") == "completed"])
|
| 331 |
+
failed_actions = len([a for a in self.execution_history if a.get("status") == "failed"])
|
| 332 |
+
active_actions = len(self.active_actions)
|
| 333 |
+
|
| 334 |
+
return {
|
| 335 |
+
"total_actions": total_actions,
|
| 336 |
+
"completed_actions": completed_actions,
|
| 337 |
+
"failed_actions": failed_actions,
|
| 338 |
+
"active_actions": active_actions,
|
| 339 |
+
"success_rate": completed_actions / total_actions if total_actions > 0 else 0
|
| 340 |
+
}
|
src/fastmcp/reflexive/engine.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reflexive Core Engine - Main runtime for self-monitoring and corrective actions."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import json
|
| 5 |
+
import hashlib
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from enum import Enum
|
| 8 |
+
from typing import Any, Dict, List, Optional, Callable, Union
|
| 9 |
+
from uuid import UUID, uuid4
|
| 10 |
+
|
| 11 |
+
from pydantic import BaseModel, Field
|
| 12 |
+
|
| 13 |
+
from fastmcp.utilities.logging import get_logger
|
| 14 |
+
|
| 15 |
+
logger = get_logger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class DecisionType(str, Enum):
|
| 19 |
+
"""Types of reflexive decisions."""
|
| 20 |
+
HALT = "halt"
|
| 21 |
+
ESCALATE = "escalate"
|
| 22 |
+
MONITOR = "monitor"
|
| 23 |
+
ALLOW = "allow"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class RiskLevel(str, Enum):
|
| 27 |
+
"""Risk levels for reflexive decisions."""
|
| 28 |
+
LOW = "low"
|
| 29 |
+
MEDIUM = "medium"
|
| 30 |
+
HIGH = "high"
|
| 31 |
+
CRITICAL = "critical"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ActionContext(BaseModel):
|
| 35 |
+
"""Context for an action being evaluated by the reflexive core."""
|
| 36 |
+
|
| 37 |
+
action_id: str = Field(..., description="Unique identifier for the action")
|
| 38 |
+
actor_id: str = Field(..., description="ID of the entity performing the action")
|
| 39 |
+
action_type: str = Field(..., description="Type of action being performed")
|
| 40 |
+
resource_id: Optional[str] = Field(default=None, description="ID of the resource being accessed")
|
| 41 |
+
metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional action metadata")
|
| 42 |
+
timestamp: datetime = Field(default_factory=datetime.utcnow, description="When the action occurred")
|
| 43 |
+
session_id: Optional[str] = Field(default=None, description="Session identifier")
|
| 44 |
+
request_id: Optional[str] = Field(default=None, description="Request identifier")
|
| 45 |
+
|
| 46 |
+
def get_context_hash(self) -> str:
|
| 47 |
+
"""Get SHA-256 hash of action context for integrity verification."""
|
| 48 |
+
content = {
|
| 49 |
+
"action_id": self.action_id,
|
| 50 |
+
"actor_id": self.actor_id,
|
| 51 |
+
"action_type": self.action_type,
|
| 52 |
+
"resource_id": self.resource_id,
|
| 53 |
+
"metadata": self.metadata,
|
| 54 |
+
"session_id": self.session_id,
|
| 55 |
+
"request_id": self.request_id
|
| 56 |
+
}
|
| 57 |
+
content_str = json.dumps(content, sort_keys=True, default=str)
|
| 58 |
+
return hashlib.sha256(content_str.encode()).hexdigest()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class ReflexiveDecision(BaseModel):
|
| 62 |
+
"""A decision made by the reflexive core."""
|
| 63 |
+
|
| 64 |
+
decision_id: UUID = Field(default_factory=uuid4, description="Unique decision identifier")
|
| 65 |
+
decision_type: DecisionType = Field(..., description="Type of decision made")
|
| 66 |
+
risk_level: RiskLevel = Field(..., description="Risk level of the situation")
|
| 67 |
+
action_context: ActionContext = Field(..., description="Context of the action being evaluated")
|
| 68 |
+
reason: str = Field(..., description="Reason for the decision")
|
| 69 |
+
evidence: Dict[str, Any] = Field(default_factory=dict, description="Evidence supporting the decision")
|
| 70 |
+
timestamp: datetime = Field(default_factory=datetime.utcnow, description="When the decision was made")
|
| 71 |
+
escalated_to: Optional[str] = Field(default=None, description="Role/entity escalated to")
|
| 72 |
+
proof_hash: Optional[str] = Field(default=None, description="Hash of decision proof")
|
| 73 |
+
|
| 74 |
+
model_config = {"use_enum_values": True}
|
| 75 |
+
|
| 76 |
+
def get_decision_hash(self) -> str:
|
| 77 |
+
"""Get SHA-256 hash of decision for integrity verification."""
|
| 78 |
+
content = {
|
| 79 |
+
"decision_id": str(self.decision_id),
|
| 80 |
+
"decision_type": self.decision_type,
|
| 81 |
+
"risk_level": self.risk_level,
|
| 82 |
+
"action_context": self.action_context.model_dump(),
|
| 83 |
+
"reason": self.reason,
|
| 84 |
+
"evidence": self.evidence,
|
| 85 |
+
"escalated_to": self.escalated_to
|
| 86 |
+
}
|
| 87 |
+
content_str = json.dumps(content, sort_keys=True, default=str)
|
| 88 |
+
return hashlib.sha256(content_str.encode()).hexdigest()
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class ReflexiveEngine:
|
| 92 |
+
"""Main reflexive core engine for self-monitoring and corrective actions."""
|
| 93 |
+
|
| 94 |
+
def __init__(self, policy_engine=None, ledger=None):
|
| 95 |
+
"""Initialize the reflexive engine.
|
| 96 |
+
|
| 97 |
+
Args:
|
| 98 |
+
policy_engine: Policy engine instance for policy monitoring
|
| 99 |
+
ledger: Provenance ledger instance for audit logging
|
| 100 |
+
"""
|
| 101 |
+
self.policy_engine = policy_engine
|
| 102 |
+
self.ledger = ledger
|
| 103 |
+
self.monitors: List[Callable] = []
|
| 104 |
+
self.decision_handlers: Dict[DecisionType, Callable] = {}
|
| 105 |
+
self.is_running = False
|
| 106 |
+
self.event_queue = asyncio.Queue()
|
| 107 |
+
|
| 108 |
+
# Register default decision handlers
|
| 109 |
+
self._register_default_handlers()
|
| 110 |
+
|
| 111 |
+
logger.info("Reflexive engine initialized")
|
| 112 |
+
|
| 113 |
+
def _register_default_handlers(self):
|
| 114 |
+
"""Register default decision handlers."""
|
| 115 |
+
self.decision_handlers[DecisionType.HALT] = self._handle_halt
|
| 116 |
+
self.decision_handlers[DecisionType.ESCALATE] = self._handle_escalate
|
| 117 |
+
self.decision_handlers[DecisionType.MONITOR] = self._handle_monitor
|
| 118 |
+
self.decision_handlers[DecisionType.ALLOW] = self._handle_allow
|
| 119 |
+
|
| 120 |
+
async def start(self):
|
| 121 |
+
"""Start the reflexive engine."""
|
| 122 |
+
if self.is_running:
|
| 123 |
+
logger.warning("Reflexive engine is already running")
|
| 124 |
+
return
|
| 125 |
+
|
| 126 |
+
self.is_running = True
|
| 127 |
+
logger.info("Reflexive engine started")
|
| 128 |
+
|
| 129 |
+
# Start the main event processing loop
|
| 130 |
+
asyncio.create_task(self._process_events())
|
| 131 |
+
|
| 132 |
+
async def stop(self):
|
| 133 |
+
"""Stop the reflexive engine."""
|
| 134 |
+
self.is_running = False
|
| 135 |
+
logger.info("Reflexive engine stopped")
|
| 136 |
+
|
| 137 |
+
async def _process_events(self):
|
| 138 |
+
"""Main event processing loop."""
|
| 139 |
+
while self.is_running:
|
| 140 |
+
try:
|
| 141 |
+
# Wait for events with timeout
|
| 142 |
+
event = await asyncio.wait_for(self.event_queue.get(), timeout=1.0)
|
| 143 |
+
await self._handle_event(event)
|
| 144 |
+
except asyncio.TimeoutError:
|
| 145 |
+
# No events, continue
|
| 146 |
+
continue
|
| 147 |
+
except Exception as e:
|
| 148 |
+
logger.error(f"Error processing reflexive event: {e}")
|
| 149 |
+
|
| 150 |
+
async def _handle_event(self, event: Dict[str, Any]):
|
| 151 |
+
"""Handle a reflexive event."""
|
| 152 |
+
try:
|
| 153 |
+
# Create action context from event
|
| 154 |
+
action_context = ActionContext(**event.get("action_context", {}))
|
| 155 |
+
|
| 156 |
+
# Evaluate the action
|
| 157 |
+
decision = await self._evaluate_action(action_context)
|
| 158 |
+
|
| 159 |
+
# Execute the decision
|
| 160 |
+
await self._execute_decision(decision)
|
| 161 |
+
|
| 162 |
+
# Log the decision to audit trail
|
| 163 |
+
await self._log_decision(decision)
|
| 164 |
+
|
| 165 |
+
except Exception as e:
|
| 166 |
+
logger.error(f"Error handling reflexive event: {e}")
|
| 167 |
+
|
| 168 |
+
async def _evaluate_action(self, action_context: ActionContext) -> ReflexiveDecision:
|
| 169 |
+
"""Evaluate an action and make a reflexive decision."""
|
| 170 |
+
# Run all monitors
|
| 171 |
+
violations = []
|
| 172 |
+
anomalies = []
|
| 173 |
+
|
| 174 |
+
for monitor in self.monitors:
|
| 175 |
+
try:
|
| 176 |
+
# Check if monitor is async
|
| 177 |
+
if asyncio.iscoroutinefunction(monitor):
|
| 178 |
+
result = await monitor(action_context)
|
| 179 |
+
else:
|
| 180 |
+
result = monitor(action_context)
|
| 181 |
+
if result:
|
| 182 |
+
if result.get("type") == "violation":
|
| 183 |
+
violations.append(result)
|
| 184 |
+
elif result.get("type") == "anomaly":
|
| 185 |
+
anomalies.append(result)
|
| 186 |
+
except Exception as e:
|
| 187 |
+
logger.error(f"Monitor error: {e}")
|
| 188 |
+
|
| 189 |
+
# Make decision based on findings
|
| 190 |
+
if violations or anomalies:
|
| 191 |
+
# Determine risk level
|
| 192 |
+
risk_level = self._assess_risk_level(violations, anomalies)
|
| 193 |
+
|
| 194 |
+
# Make decision based on risk level
|
| 195 |
+
if risk_level == RiskLevel.CRITICAL:
|
| 196 |
+
decision_type = DecisionType.HALT
|
| 197 |
+
reason = f"Critical risk detected: {len(violations)} violations, {len(anomalies)} anomalies"
|
| 198 |
+
elif risk_level == RiskLevel.HIGH:
|
| 199 |
+
decision_type = DecisionType.HALT
|
| 200 |
+
reason = f"High risk detected: {len(violations)} violations, {len(anomalies)} anomalies"
|
| 201 |
+
elif risk_level == RiskLevel.MEDIUM:
|
| 202 |
+
decision_type = DecisionType.ESCALATE
|
| 203 |
+
reason = f"Medium risk detected: {len(violations)} violations, {len(anomalies)} anomalies"
|
| 204 |
+
else:
|
| 205 |
+
decision_type = DecisionType.MONITOR
|
| 206 |
+
reason = f"Low risk detected: {len(violations)} violations, {len(anomalies)} anomalies"
|
| 207 |
+
else:
|
| 208 |
+
decision_type = DecisionType.ALLOW
|
| 209 |
+
reason = "No violations or anomalies detected"
|
| 210 |
+
risk_level = RiskLevel.LOW
|
| 211 |
+
|
| 212 |
+
# Create decision
|
| 213 |
+
decision = ReflexiveDecision(
|
| 214 |
+
decision_type=decision_type,
|
| 215 |
+
risk_level=risk_level,
|
| 216 |
+
action_context=action_context,
|
| 217 |
+
reason=reason,
|
| 218 |
+
evidence={
|
| 219 |
+
"violations": violations,
|
| 220 |
+
"anomalies": anomalies
|
| 221 |
+
}
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
# Set proof hash
|
| 225 |
+
decision.proof_hash = decision.get_decision_hash()
|
| 226 |
+
|
| 227 |
+
return decision
|
| 228 |
+
|
| 229 |
+
def _assess_risk_level(self, violations: List[Dict], anomalies: List[Dict]) -> RiskLevel:
|
| 230 |
+
"""Assess the overall risk level based on violations and anomalies."""
|
| 231 |
+
total_issues = len(violations) + len(anomalies)
|
| 232 |
+
|
| 233 |
+
# Check for critical violations or anomalies
|
| 234 |
+
critical_violations = [v for v in violations if v.get("severity") == "critical"]
|
| 235 |
+
critical_anomalies = [a for a in anomalies if a.get("severity") == "critical"]
|
| 236 |
+
if critical_violations or critical_anomalies:
|
| 237 |
+
return RiskLevel.CRITICAL
|
| 238 |
+
|
| 239 |
+
# Check for high severity issues
|
| 240 |
+
high_violations = [v for v in violations if v.get("severity") == "high"]
|
| 241 |
+
high_anomalies = [a for a in anomalies if a.get("severity") == "high"]
|
| 242 |
+
if high_violations or high_anomalies or total_issues >= 5:
|
| 243 |
+
return RiskLevel.HIGH
|
| 244 |
+
|
| 245 |
+
# Check for medium severity issues
|
| 246 |
+
medium_violations = [v for v in violations if v.get("severity") == "medium"]
|
| 247 |
+
medium_anomalies = [a for a in anomalies if a.get("severity") == "medium"]
|
| 248 |
+
if medium_violations or medium_anomalies or total_issues >= 2:
|
| 249 |
+
return RiskLevel.MEDIUM
|
| 250 |
+
|
| 251 |
+
return RiskLevel.LOW
|
| 252 |
+
|
| 253 |
+
async def _execute_decision(self, decision: ReflexiveDecision):
|
| 254 |
+
"""Execute a reflexive decision."""
|
| 255 |
+
handler = self.decision_handlers.get(decision.decision_type)
|
| 256 |
+
if handler:
|
| 257 |
+
try:
|
| 258 |
+
await handler(decision)
|
| 259 |
+
except Exception as e:
|
| 260 |
+
logger.error(f"Error executing decision {decision.decision_type}: {e}")
|
| 261 |
+
else:
|
| 262 |
+
logger.warning(f"No handler for decision type: {decision.decision_type}")
|
| 263 |
+
|
| 264 |
+
async def _handle_halt(self, decision: ReflexiveDecision):
|
| 265 |
+
"""Handle a halt decision."""
|
| 266 |
+
logger.critical(f"HALTING ACTION: {decision.action_context.action_id} - {decision.reason}")
|
| 267 |
+
# In a real implementation, this would stop the action execution
|
| 268 |
+
# For now, we just log the halt decision
|
| 269 |
+
|
| 270 |
+
async def _handle_escalate(self, decision: ReflexiveDecision):
|
| 271 |
+
"""Handle an escalate decision."""
|
| 272 |
+
# Determine escalation target
|
| 273 |
+
escalation_target = self._determine_escalation_target(decision)
|
| 274 |
+
decision.escalated_to = escalation_target
|
| 275 |
+
|
| 276 |
+
logger.warning(f"ESCALATING TO {escalation_target}: {decision.action_context.action_id} - {decision.reason}")
|
| 277 |
+
# In a real implementation, this would notify the escalation target
|
| 278 |
+
|
| 279 |
+
async def _handle_monitor(self, decision: ReflexiveDecision):
|
| 280 |
+
"""Handle a monitor decision."""
|
| 281 |
+
logger.info(f"MONITORING ACTION: {decision.action_context.action_id} - {decision.reason}")
|
| 282 |
+
# In a real implementation, this would increase monitoring for this action
|
| 283 |
+
|
| 284 |
+
async def _handle_allow(self, decision: ReflexiveDecision):
|
| 285 |
+
"""Handle an allow decision."""
|
| 286 |
+
logger.debug(f"ALLOWING ACTION: {decision.action_context.action_id} - {decision.reason}")
|
| 287 |
+
# Action is allowed to proceed
|
| 288 |
+
|
| 289 |
+
def _determine_escalation_target(self, decision: ReflexiveDecision) -> str:
|
| 290 |
+
"""Determine the appropriate escalation target based on the decision."""
|
| 291 |
+
if decision.risk_level == RiskLevel.CRITICAL:
|
| 292 |
+
return "security_admin"
|
| 293 |
+
elif decision.risk_level == RiskLevel.HIGH:
|
| 294 |
+
return "system_admin"
|
| 295 |
+
else:
|
| 296 |
+
return "monitoring_team"
|
| 297 |
+
|
| 298 |
+
async def _log_decision(self, decision: ReflexiveDecision):
|
| 299 |
+
"""Log the reflexive decision to the audit trail."""
|
| 300 |
+
if self.ledger:
|
| 301 |
+
try:
|
| 302 |
+
from fastmcp.ledger import LedgerEvent, EventType
|
| 303 |
+
|
| 304 |
+
event = LedgerEvent(
|
| 305 |
+
event_type=EventType.REFLEXIVE_DECISION,
|
| 306 |
+
actor_id="reflexive_core",
|
| 307 |
+
resource_id=decision.action_context.action_id,
|
| 308 |
+
action=f"reflexive_{decision.decision_type}",
|
| 309 |
+
metadata={
|
| 310 |
+
"decision_id": str(decision.decision_id),
|
| 311 |
+
"risk_level": decision.risk_level,
|
| 312 |
+
"reason": decision.reason,
|
| 313 |
+
"proof_hash": decision.proof_hash,
|
| 314 |
+
"escalated_to": decision.escalated_to
|
| 315 |
+
}
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
self.ledger.append_event(event)
|
| 319 |
+
logger.debug(f"Logged reflexive decision {decision.decision_id} to audit trail")
|
| 320 |
+
|
| 321 |
+
except Exception as e:
|
| 322 |
+
logger.error(f"Failed to log reflexive decision: {e}")
|
| 323 |
+
else:
|
| 324 |
+
# Log to standard logger if no ledger is available
|
| 325 |
+
logger.info(f"Reflexive decision: {decision.decision_type} - {decision.reason} (Decision ID: {decision.decision_id})")
|
| 326 |
+
|
| 327 |
+
def add_monitor(self, monitor: Callable):
|
| 328 |
+
"""Add a monitor function to the reflexive engine."""
|
| 329 |
+
self.monitors.append(monitor)
|
| 330 |
+
logger.info(f"Added monitor: {monitor.__name__}")
|
| 331 |
+
|
| 332 |
+
def remove_monitor(self, monitor: Callable):
|
| 333 |
+
"""Remove a monitor function from the reflexive engine."""
|
| 334 |
+
if monitor in self.monitors:
|
| 335 |
+
self.monitors.remove(monitor)
|
| 336 |
+
logger.info(f"Removed monitor: {monitor.__name__}")
|
| 337 |
+
|
| 338 |
+
async def submit_action(self, action_context: ActionContext):
|
| 339 |
+
"""Submit an action for reflexive evaluation."""
|
| 340 |
+
event = {
|
| 341 |
+
"action_context": action_context.model_dump(),
|
| 342 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
await self.event_queue.put(event)
|
| 346 |
+
logger.debug(f"Submitted action {action_context.action_id} for reflexive evaluation")
|
| 347 |
+
|
| 348 |
+
async def simulate_risk(self, risk_scenario: Dict[str, Any]) -> ReflexiveDecision:
|
| 349 |
+
"""Simulate a risk scenario and return the reflexive decision."""
|
| 350 |
+
# Create action context from scenario
|
| 351 |
+
action_context = ActionContext(**risk_scenario.get("action_context", {}))
|
| 352 |
+
|
| 353 |
+
# Override monitors temporarily for simulation
|
| 354 |
+
original_monitors = self.monitors.copy()
|
| 355 |
+
|
| 356 |
+
# Add simulation monitors
|
| 357 |
+
simulation_monitors = risk_scenario.get("monitors", [])
|
| 358 |
+
for monitor_func in simulation_monitors:
|
| 359 |
+
self.monitors.append(monitor_func)
|
| 360 |
+
|
| 361 |
+
try:
|
| 362 |
+
# Evaluate the action
|
| 363 |
+
decision = await self._evaluate_action(action_context)
|
| 364 |
+
return decision
|
| 365 |
+
finally:
|
| 366 |
+
# Restore original monitors
|
| 367 |
+
self.monitors = original_monitors
|
| 368 |
+
|
| 369 |
+
def get_engine_status(self) -> Dict[str, Any]:
|
| 370 |
+
"""Get the current status of the reflexive engine."""
|
| 371 |
+
return {
|
| 372 |
+
"is_running": self.is_running,
|
| 373 |
+
"monitor_count": len(self.monitors),
|
| 374 |
+
"queue_size": self.event_queue.qsize(),
|
| 375 |
+
"decision_handlers": list(self.decision_handlers.keys())
|
| 376 |
+
}
|
src/fastmcp/reflexive/monitor.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Monitoring components for the reflexive core."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from typing import Any, Dict, List, Optional, Callable
|
| 6 |
+
from collections import defaultdict, deque
|
| 7 |
+
|
| 8 |
+
from fastmcp.utilities.logging import get_logger
|
| 9 |
+
|
| 10 |
+
logger = get_logger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class PolicyMonitor:
|
| 14 |
+
"""Monitor for policy violations."""
|
| 15 |
+
|
| 16 |
+
def __init__(self, policy_engine=None):
|
| 17 |
+
"""Initialize the policy monitor.
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
policy_engine: Policy engine instance to monitor
|
| 21 |
+
"""
|
| 22 |
+
self.policy_engine = policy_engine
|
| 23 |
+
self.violation_history = deque(maxlen=1000) # Keep last 1000 violations
|
| 24 |
+
self.actor_violations = defaultdict(int) # Track violations per actor
|
| 25 |
+
|
| 26 |
+
async def __call__(self, action_context) -> Optional[Dict[str, Any]]:
|
| 27 |
+
"""Monitor an action for policy violations."""
|
| 28 |
+
try:
|
| 29 |
+
# Check for policy violations (works with or without policy engine)
|
| 30 |
+
violations = await self._check_policy_violations(action_context)
|
| 31 |
+
|
| 32 |
+
if violations:
|
| 33 |
+
# Record violation
|
| 34 |
+
violation_record = {
|
| 35 |
+
"type": "violation",
|
| 36 |
+
"severity": self._assess_violation_severity(violations),
|
| 37 |
+
"violations": violations,
|
| 38 |
+
"actor_id": action_context.actor_id,
|
| 39 |
+
"action_id": action_context.action_id,
|
| 40 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
self.violation_history.append(violation_record)
|
| 44 |
+
self.actor_violations[action_context.actor_id] += 1
|
| 45 |
+
|
| 46 |
+
return violation_record
|
| 47 |
+
|
| 48 |
+
return None
|
| 49 |
+
|
| 50 |
+
except Exception as e:
|
| 51 |
+
logger.error(f"Policy monitor error: {e}")
|
| 52 |
+
return None
|
| 53 |
+
|
| 54 |
+
async def _check_policy_violations(self, action_context) -> List[Dict[str, Any]]:
|
| 55 |
+
"""Check for policy violations in an action."""
|
| 56 |
+
violations = []
|
| 57 |
+
|
| 58 |
+
try:
|
| 59 |
+
# Simulate policy checking
|
| 60 |
+
# In a real implementation, this would use the actual policy engine
|
| 61 |
+
|
| 62 |
+
# Check for suspicious patterns
|
| 63 |
+
if action_context.action_type == "admin_access" and action_context.actor_id.startswith("guest"):
|
| 64 |
+
violations.append({
|
| 65 |
+
"rule": "admin_access_restriction",
|
| 66 |
+
"message": "Guest user attempting admin access",
|
| 67 |
+
"severity": "high"
|
| 68 |
+
})
|
| 69 |
+
|
| 70 |
+
# Check for rate limiting
|
| 71 |
+
recent_violations = [v for v in self.violation_history
|
| 72 |
+
if v.get("actor_id") == action_context.actor_id
|
| 73 |
+
and datetime.fromisoformat(v["timestamp"]) > datetime.utcnow() - timedelta(minutes=5)]
|
| 74 |
+
|
| 75 |
+
if len(recent_violations) >= 3:
|
| 76 |
+
violations.append({
|
| 77 |
+
"rule": "rate_limit_exceeded",
|
| 78 |
+
"message": f"Actor {action_context.actor_id} has {len(recent_violations)} recent violations",
|
| 79 |
+
"severity": "medium"
|
| 80 |
+
})
|
| 81 |
+
|
| 82 |
+
# Check for resource access patterns
|
| 83 |
+
if action_context.resource_id and "sensitive" in action_context.resource_id.lower():
|
| 84 |
+
if not action_context.metadata.get("authorized"):
|
| 85 |
+
violations.append({
|
| 86 |
+
"rule": "unauthorized_sensitive_access",
|
| 87 |
+
"message": "Unauthorized access to sensitive resource",
|
| 88 |
+
"severity": "critical"
|
| 89 |
+
})
|
| 90 |
+
|
| 91 |
+
except Exception as e:
|
| 92 |
+
logger.error(f"Error checking policy violations: {e}")
|
| 93 |
+
|
| 94 |
+
return violations
|
| 95 |
+
|
| 96 |
+
def _assess_violation_severity(self, violations: List[Dict[str, Any]]) -> str:
|
| 97 |
+
"""Assess the overall severity of violations."""
|
| 98 |
+
if not violations:
|
| 99 |
+
return "low"
|
| 100 |
+
|
| 101 |
+
severities = [v.get("severity", "low") for v in violations]
|
| 102 |
+
|
| 103 |
+
if "critical" in severities:
|
| 104 |
+
return "critical"
|
| 105 |
+
elif "high" in severities:
|
| 106 |
+
return "high"
|
| 107 |
+
elif "medium" in severities:
|
| 108 |
+
return "medium"
|
| 109 |
+
else:
|
| 110 |
+
return "low"
|
| 111 |
+
|
| 112 |
+
def get_violation_stats(self) -> Dict[str, Any]:
|
| 113 |
+
"""Get violation statistics."""
|
| 114 |
+
return {
|
| 115 |
+
"total_violations": len(self.violation_history),
|
| 116 |
+
"actor_violations": dict(self.actor_violations),
|
| 117 |
+
"recent_violations": len([v for v in self.violation_history
|
| 118 |
+
if datetime.fromisoformat(v["timestamp"]) > datetime.utcnow() - timedelta(hours=1)])
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class LedgerMonitor:
|
| 123 |
+
"""Monitor for ledger inconsistencies and anomalies."""
|
| 124 |
+
|
| 125 |
+
def __init__(self, ledger=None):
|
| 126 |
+
"""Initialize the ledger monitor.
|
| 127 |
+
|
| 128 |
+
Args:
|
| 129 |
+
ledger: Provenance ledger instance to monitor
|
| 130 |
+
"""
|
| 131 |
+
self.ledger = ledger
|
| 132 |
+
self.integrity_checks = deque(maxlen=100) # Keep last 100 integrity checks
|
| 133 |
+
|
| 134 |
+
async def __call__(self, action_context) -> Optional[Dict[str, Any]]:
|
| 135 |
+
"""Monitor ledger for inconsistencies."""
|
| 136 |
+
try:
|
| 137 |
+
if not self.ledger:
|
| 138 |
+
return None
|
| 139 |
+
|
| 140 |
+
# Check ledger integrity
|
| 141 |
+
integrity_issues = await self._check_ledger_integrity()
|
| 142 |
+
|
| 143 |
+
if integrity_issues:
|
| 144 |
+
# Record integrity issue
|
| 145 |
+
issue_record = {
|
| 146 |
+
"type": "anomaly",
|
| 147 |
+
"severity": self._assess_integrity_severity(integrity_issues),
|
| 148 |
+
"issues": integrity_issues,
|
| 149 |
+
"action_id": action_context.action_id,
|
| 150 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
self.integrity_checks.append(issue_record)
|
| 154 |
+
return issue_record
|
| 155 |
+
|
| 156 |
+
return None
|
| 157 |
+
|
| 158 |
+
except Exception as e:
|
| 159 |
+
logger.error(f"Ledger monitor error: {e}")
|
| 160 |
+
return None
|
| 161 |
+
|
| 162 |
+
async def _check_ledger_integrity(self) -> List[Dict[str, Any]]:
|
| 163 |
+
"""Check ledger for integrity issues."""
|
| 164 |
+
issues = []
|
| 165 |
+
|
| 166 |
+
try:
|
| 167 |
+
# Check chain integrity
|
| 168 |
+
is_valid = self.ledger.verify_chain_integrity()
|
| 169 |
+
if not is_valid:
|
| 170 |
+
issues.append({
|
| 171 |
+
"type": "chain_integrity",
|
| 172 |
+
"message": "Ledger chain integrity verification failed",
|
| 173 |
+
"severity": "critical"
|
| 174 |
+
})
|
| 175 |
+
|
| 176 |
+
# Check for missing blocks
|
| 177 |
+
stats = self.ledger.get_ledger_statistics()
|
| 178 |
+
if stats.get("total_entries", 0) > 0 and stats.get("total_blocks", 0) == 0:
|
| 179 |
+
issues.append({
|
| 180 |
+
"type": "missing_blocks",
|
| 181 |
+
"message": "Entries exist but no blocks found",
|
| 182 |
+
"severity": "high"
|
| 183 |
+
})
|
| 184 |
+
|
| 185 |
+
# Check for unsealed blocks
|
| 186 |
+
# This would require additional ledger methods to check for unsealed blocks
|
| 187 |
+
|
| 188 |
+
except Exception as e:
|
| 189 |
+
logger.error(f"Error checking ledger integrity: {e}")
|
| 190 |
+
issues.append({
|
| 191 |
+
"type": "integrity_check_error",
|
| 192 |
+
"message": f"Error during integrity check: {str(e)}",
|
| 193 |
+
"severity": "medium"
|
| 194 |
+
})
|
| 195 |
+
|
| 196 |
+
return issues
|
| 197 |
+
|
| 198 |
+
def _assess_integrity_severity(self, issues: List[Dict[str, Any]]) -> str:
|
| 199 |
+
"""Assess the overall severity of integrity issues."""
|
| 200 |
+
if not issues:
|
| 201 |
+
return "low"
|
| 202 |
+
|
| 203 |
+
severities = [i.get("severity", "low") for i in issues]
|
| 204 |
+
|
| 205 |
+
if "critical" in severities:
|
| 206 |
+
return "critical"
|
| 207 |
+
elif "high" in severities:
|
| 208 |
+
return "high"
|
| 209 |
+
elif "medium" in severities:
|
| 210 |
+
return "medium"
|
| 211 |
+
else:
|
| 212 |
+
return "low"
|
| 213 |
+
|
| 214 |
+
def get_integrity_stats(self) -> Dict[str, Any]:
|
| 215 |
+
"""Get integrity check statistics."""
|
| 216 |
+
return {
|
| 217 |
+
"total_checks": len(self.integrity_checks),
|
| 218 |
+
"recent_issues": len([i for i in self.integrity_checks
|
| 219 |
+
if datetime.fromisoformat(i["timestamp"]) > datetime.utcnow() - timedelta(hours=1)])
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
class AnomalyDetector:
|
| 224 |
+
"""Detector for behavioral anomalies."""
|
| 225 |
+
|
| 226 |
+
def __init__(self):
|
| 227 |
+
"""Initialize the anomaly detector."""
|
| 228 |
+
self.actor_patterns = defaultdict(lambda: {
|
| 229 |
+
"action_counts": defaultdict(int),
|
| 230 |
+
"resource_access": defaultdict(int),
|
| 231 |
+
"session_times": deque(maxlen=100),
|
| 232 |
+
"last_seen": None
|
| 233 |
+
})
|
| 234 |
+
self.global_patterns = {
|
| 235 |
+
"action_frequency": defaultdict(int),
|
| 236 |
+
"resource_access": defaultdict(int),
|
| 237 |
+
"time_patterns": defaultdict(int)
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
async def __call__(self, action_context) -> Optional[Dict[str, Any]]:
|
| 241 |
+
"""Detect anomalies in an action."""
|
| 242 |
+
try:
|
| 243 |
+
# Update patterns
|
| 244 |
+
self._update_patterns(action_context)
|
| 245 |
+
|
| 246 |
+
# Detect anomalies
|
| 247 |
+
anomalies = await self._detect_anomalies(action_context)
|
| 248 |
+
|
| 249 |
+
if anomalies:
|
| 250 |
+
return {
|
| 251 |
+
"type": "anomaly",
|
| 252 |
+
"severity": self._assess_anomaly_severity(anomalies),
|
| 253 |
+
"anomalies": anomalies,
|
| 254 |
+
"actor_id": action_context.actor_id,
|
| 255 |
+
"action_id": action_context.action_id,
|
| 256 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
return None
|
| 260 |
+
|
| 261 |
+
except Exception as e:
|
| 262 |
+
logger.error(f"Anomaly detector error: {e}")
|
| 263 |
+
return None
|
| 264 |
+
|
| 265 |
+
def _update_patterns(self, action_context):
|
| 266 |
+
"""Update behavioral patterns."""
|
| 267 |
+
actor_id = action_context.actor_id
|
| 268 |
+
actor_data = self.actor_patterns[actor_id]
|
| 269 |
+
|
| 270 |
+
# Update action counts
|
| 271 |
+
actor_data["action_counts"][action_context.action_type] += 1
|
| 272 |
+
self.global_patterns["action_frequency"][action_context.action_type] += 1
|
| 273 |
+
|
| 274 |
+
# Update resource access
|
| 275 |
+
if action_context.resource_id:
|
| 276 |
+
actor_data["resource_access"][action_context.resource_id] += 1
|
| 277 |
+
self.global_patterns["resource_access"][action_context.resource_id] += 1
|
| 278 |
+
|
| 279 |
+
# Update session times
|
| 280 |
+
actor_data["session_times"].append(action_context.timestamp)
|
| 281 |
+
actor_data["last_seen"] = action_context.timestamp
|
| 282 |
+
|
| 283 |
+
# Update time patterns
|
| 284 |
+
hour = action_context.timestamp.hour
|
| 285 |
+
self.global_patterns["time_patterns"][hour] += 1
|
| 286 |
+
|
| 287 |
+
async def _detect_anomalies(self, action_context) -> List[Dict[str, Any]]:
|
| 288 |
+
"""Detect anomalies in the action."""
|
| 289 |
+
anomalies = []
|
| 290 |
+
actor_id = action_context.actor_id
|
| 291 |
+
actor_data = self.actor_patterns[actor_id]
|
| 292 |
+
|
| 293 |
+
# Check for unusual action frequency
|
| 294 |
+
if len(actor_data["session_times"]) >= 10:
|
| 295 |
+
recent_actions = [t for t in actor_data["session_times"]
|
| 296 |
+
if t > datetime.utcnow() - timedelta(minutes=5)]
|
| 297 |
+
if len(recent_actions) > 20: # More than 20 actions in 5 minutes
|
| 298 |
+
anomalies.append({
|
| 299 |
+
"type": "high_frequency",
|
| 300 |
+
"message": f"Actor {actor_id} performing {len(recent_actions)} actions in 5 minutes",
|
| 301 |
+
"severity": "medium"
|
| 302 |
+
})
|
| 303 |
+
|
| 304 |
+
# Check for unusual time patterns
|
| 305 |
+
current_hour = action_context.timestamp.hour
|
| 306 |
+
if current_hour < 6 or current_hour > 22: # Unusual hours
|
| 307 |
+
if actor_data["action_counts"].get(action_context.action_type, 0) < 5: # New action type
|
| 308 |
+
anomalies.append({
|
| 309 |
+
"type": "unusual_timing",
|
| 310 |
+
"message": f"Actor {actor_id} performing {action_context.action_type} at unusual hour {current_hour}",
|
| 311 |
+
"severity": "low"
|
| 312 |
+
})
|
| 313 |
+
|
| 314 |
+
# Check for new resource access
|
| 315 |
+
if action_context.resource_id:
|
| 316 |
+
if actor_data["resource_access"].get(action_context.resource_id, 0) == 1:
|
| 317 |
+
# First time accessing this resource
|
| 318 |
+
anomalies.append({
|
| 319 |
+
"type": "new_resource_access",
|
| 320 |
+
"message": f"Actor {actor_id} accessing new resource {action_context.resource_id}",
|
| 321 |
+
"severity": "low"
|
| 322 |
+
})
|
| 323 |
+
|
| 324 |
+
# Check for privilege escalation patterns
|
| 325 |
+
if action_context.action_type in ["admin_access", "root_access", "privilege_escalation"]:
|
| 326 |
+
if actor_data["action_counts"].get(action_context.action_type, 0) == 1:
|
| 327 |
+
# First time performing privileged action
|
| 328 |
+
anomalies.append({
|
| 329 |
+
"type": "privilege_escalation",
|
| 330 |
+
"message": f"Actor {actor_id} attempting privileged action for first time",
|
| 331 |
+
"severity": "high"
|
| 332 |
+
})
|
| 333 |
+
|
| 334 |
+
return anomalies
|
| 335 |
+
|
| 336 |
+
def _assess_anomaly_severity(self, anomalies: List[Dict[str, Any]]) -> str:
|
| 337 |
+
"""Assess the overall severity of anomalies."""
|
| 338 |
+
if not anomalies:
|
| 339 |
+
return "low"
|
| 340 |
+
|
| 341 |
+
severities = [a.get("severity", "low") for a in anomalies]
|
| 342 |
+
|
| 343 |
+
if "high" in severities:
|
| 344 |
+
return "high"
|
| 345 |
+
elif "medium" in severities:
|
| 346 |
+
return "medium"
|
| 347 |
+
else:
|
| 348 |
+
return "low"
|
| 349 |
+
|
| 350 |
+
def get_anomaly_stats(self) -> Dict[str, Any]:
|
| 351 |
+
"""Get anomaly detection statistics."""
|
| 352 |
+
return {
|
| 353 |
+
"tracked_actors": len(self.actor_patterns),
|
| 354 |
+
"global_action_types": len(self.global_patterns["action_frequency"]),
|
| 355 |
+
"global_resources": len(self.global_patterns["resource_access"])
|
| 356 |
+
}
|
src/fastmcp/server/reflexive_routes.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reflexive Core HTTP routes."""
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict, List, Optional
|
| 4 |
+
from uuid import UUID
|
| 5 |
+
|
| 6 |
+
from starlette.requests import Request
|
| 7 |
+
from starlette.responses import JSONResponse
|
| 8 |
+
from starlette.routing import Route
|
| 9 |
+
|
| 10 |
+
from fastmcp.reflexive import ReflexiveEngine, ActionContext, DecisionType, RiskLevel
|
| 11 |
+
from fastmcp.reflexive.actions import ActionFactory, ActionExecutor
|
| 12 |
+
from fastmcp.utilities.logging import get_logger
|
| 13 |
+
|
| 14 |
+
logger = get_logger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
async def simulate_risk_endpoint(request: Request) -> JSONResponse:
|
| 18 |
+
"""HTTP endpoint for simulating risk scenarios.
|
| 19 |
+
|
| 20 |
+
Expected JSON body:
|
| 21 |
+
{
|
| 22 |
+
"action_context": {
|
| 23 |
+
"action_id": "test_action_123",
|
| 24 |
+
"actor_id": "test_user",
|
| 25 |
+
"action_type": "admin_access",
|
| 26 |
+
"resource_id": "sensitive_data",
|
| 27 |
+
"metadata": {...}
|
| 28 |
+
},
|
| 29 |
+
"monitors": [
|
| 30 |
+
// List of monitor functions (for simulation)
|
| 31 |
+
],
|
| 32 |
+
"scenario_type": "policy_violation" | "anomaly" | "integrity_issue"
|
| 33 |
+
}
|
| 34 |
+
"""
|
| 35 |
+
try:
|
| 36 |
+
# Parse request body
|
| 37 |
+
body = await request.json()
|
| 38 |
+
|
| 39 |
+
# Get reflexive engine from request state
|
| 40 |
+
reflexive_engine: ReflexiveEngine = request.app.state.reflexive_engine
|
| 41 |
+
|
| 42 |
+
# Validate required fields
|
| 43 |
+
if "action_context" not in body:
|
| 44 |
+
return JSONResponse(
|
| 45 |
+
status_code=400,
|
| 46 |
+
content={
|
| 47 |
+
"error": "Missing required field: action_context"
|
| 48 |
+
}
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
# Simulate the risk scenario
|
| 52 |
+
decision = await reflexive_engine.simulate_risk(body)
|
| 53 |
+
|
| 54 |
+
# Create and execute the corresponding action
|
| 55 |
+
action = ActionFactory.create_action(decision)
|
| 56 |
+
executor = ActionExecutor()
|
| 57 |
+
action_result = await executor.execute_action(action)
|
| 58 |
+
|
| 59 |
+
return JSONResponse(
|
| 60 |
+
status_code=200,
|
| 61 |
+
content={
|
| 62 |
+
"simulation_id": str(decision.decision_id),
|
| 63 |
+
"decision": {
|
| 64 |
+
"decision_id": str(decision.decision_id),
|
| 65 |
+
"decision_type": decision.decision_type,
|
| 66 |
+
"risk_level": decision.risk_level,
|
| 67 |
+
"reason": decision.reason,
|
| 68 |
+
"evidence": decision.evidence,
|
| 69 |
+
"proof_hash": decision.proof_hash,
|
| 70 |
+
"escalated_to": decision.escalated_to,
|
| 71 |
+
"timestamp": decision.timestamp.isoformat()
|
| 72 |
+
},
|
| 73 |
+
"action": {
|
| 74 |
+
"action_id": str(action.action_id),
|
| 75 |
+
"action_type": action.get_action_type(),
|
| 76 |
+
"status": action.status,
|
| 77 |
+
"result": action_result
|
| 78 |
+
},
|
| 79 |
+
"action_context": decision.action_context.model_dump(mode='json')
|
| 80 |
+
}
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
except ValueError as e:
|
| 84 |
+
logger.error(f"Invalid simulation data: {e}")
|
| 85 |
+
return JSONResponse(
|
| 86 |
+
status_code=400,
|
| 87 |
+
content={
|
| 88 |
+
"error": "Invalid simulation data",
|
| 89 |
+
"reason": str(e)
|
| 90 |
+
}
|
| 91 |
+
)
|
| 92 |
+
except Exception as e:
|
| 93 |
+
logger.error(f"Failed to simulate risk: {e}")
|
| 94 |
+
return JSONResponse(
|
| 95 |
+
status_code=500,
|
| 96 |
+
content={
|
| 97 |
+
"error": "Failed to simulate risk",
|
| 98 |
+
"reason": str(e)
|
| 99 |
+
}
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
async def get_engine_status_endpoint(request: Request) -> JSONResponse:
|
| 104 |
+
"""HTTP endpoint for getting reflexive engine status."""
|
| 105 |
+
try:
|
| 106 |
+
# Get reflexive engine from request state
|
| 107 |
+
reflexive_engine: ReflexiveEngine = request.app.state.reflexive_engine
|
| 108 |
+
|
| 109 |
+
# Get engine status
|
| 110 |
+
status = reflexive_engine.get_engine_status()
|
| 111 |
+
|
| 112 |
+
return JSONResponse(
|
| 113 |
+
status_code=200,
|
| 114 |
+
content=status
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
except Exception as e:
|
| 118 |
+
logger.error(f"Failed to get engine status: {e}")
|
| 119 |
+
return JSONResponse(
|
| 120 |
+
status_code=500,
|
| 121 |
+
content={
|
| 122 |
+
"error": "Failed to get engine status",
|
| 123 |
+
"reason": str(e)
|
| 124 |
+
}
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
async def submit_action_endpoint(request: Request) -> JSONResponse:
|
| 129 |
+
"""HTTP endpoint for submitting an action for reflexive evaluation.
|
| 130 |
+
|
| 131 |
+
Expected JSON body:
|
| 132 |
+
{
|
| 133 |
+
"action_id": "action_123",
|
| 134 |
+
"actor_id": "user_456",
|
| 135 |
+
"action_type": "tool_call",
|
| 136 |
+
"resource_id": "resource_789",
|
| 137 |
+
"metadata": {...},
|
| 138 |
+
"session_id": "session_abc",
|
| 139 |
+
"request_id": "request_def"
|
| 140 |
+
}
|
| 141 |
+
"""
|
| 142 |
+
try:
|
| 143 |
+
# Parse request body
|
| 144 |
+
body = await request.json()
|
| 145 |
+
|
| 146 |
+
# Get reflexive engine from request state
|
| 147 |
+
reflexive_engine: ReflexiveEngine = request.app.state.reflexive_engine
|
| 148 |
+
|
| 149 |
+
# Create action context
|
| 150 |
+
action_context = ActionContext(**body)
|
| 151 |
+
|
| 152 |
+
# Submit action for evaluation
|
| 153 |
+
await reflexive_engine.submit_action(action_context)
|
| 154 |
+
|
| 155 |
+
return JSONResponse(
|
| 156 |
+
status_code=202,
|
| 157 |
+
content={
|
| 158 |
+
"message": "Action submitted for reflexive evaluation",
|
| 159 |
+
"action_id": action_context.action_id,
|
| 160 |
+
"submitted_at": action_context.timestamp.isoformat()
|
| 161 |
+
}
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
except ValueError as e:
|
| 165 |
+
logger.error(f"Invalid action data: {e}")
|
| 166 |
+
return JSONResponse(
|
| 167 |
+
status_code=400,
|
| 168 |
+
content={
|
| 169 |
+
"error": "Invalid action data",
|
| 170 |
+
"reason": str(e)
|
| 171 |
+
}
|
| 172 |
+
)
|
| 173 |
+
except Exception as e:
|
| 174 |
+
logger.error(f"Failed to submit action: {e}")
|
| 175 |
+
return JSONResponse(
|
| 176 |
+
status_code=500,
|
| 177 |
+
content={
|
| 178 |
+
"error": "Failed to submit action",
|
| 179 |
+
"reason": str(e)
|
| 180 |
+
}
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
async def get_monitor_stats_endpoint(request: Request) -> JSONResponse:
|
| 185 |
+
"""HTTP endpoint for getting monitor statistics."""
|
| 186 |
+
try:
|
| 187 |
+
# Get reflexive engine from request state
|
| 188 |
+
reflexive_engine: ReflexiveEngine = request.app.state.reflexive_engine
|
| 189 |
+
|
| 190 |
+
# Collect stats from all monitors
|
| 191 |
+
stats = {}
|
| 192 |
+
|
| 193 |
+
for monitor in reflexive_engine.monitors:
|
| 194 |
+
if hasattr(monitor, 'get_violation_stats'):
|
| 195 |
+
stats['policy_monitor'] = monitor.get_violation_stats()
|
| 196 |
+
elif hasattr(monitor, 'get_integrity_stats'):
|
| 197 |
+
stats['ledger_monitor'] = monitor.get_integrity_stats()
|
| 198 |
+
elif hasattr(monitor, 'get_anomaly_stats'):
|
| 199 |
+
stats['anomaly_detector'] = monitor.get_anomaly_stats()
|
| 200 |
+
|
| 201 |
+
return JSONResponse(
|
| 202 |
+
status_code=200,
|
| 203 |
+
content=stats
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
except Exception as e:
|
| 207 |
+
logger.error(f"Failed to get monitor stats: {e}")
|
| 208 |
+
return JSONResponse(
|
| 209 |
+
status_code=500,
|
| 210 |
+
content={
|
| 211 |
+
"error": "Failed to get monitor stats",
|
| 212 |
+
"reason": str(e)
|
| 213 |
+
}
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
async def create_risk_scenario_endpoint(request: Request) -> JSONResponse:
|
| 218 |
+
"""HTTP endpoint for creating predefined risk scenarios.
|
| 219 |
+
|
| 220 |
+
Expected JSON body:
|
| 221 |
+
{
|
| 222 |
+
"scenario_name": "admin_privilege_escalation",
|
| 223 |
+
"scenario_type": "policy_violation",
|
| 224 |
+
"parameters": {
|
| 225 |
+
"actor_type": "guest_user",
|
| 226 |
+
"target_resource": "admin_panel",
|
| 227 |
+
"severity": "high"
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
"""
|
| 231 |
+
try:
|
| 232 |
+
# Parse request body
|
| 233 |
+
body = await request.json()
|
| 234 |
+
|
| 235 |
+
scenario_name = body.get("scenario_name")
|
| 236 |
+
scenario_type = body.get("scenario_type")
|
| 237 |
+
parameters = body.get("parameters", {})
|
| 238 |
+
|
| 239 |
+
if not scenario_name or not scenario_type:
|
| 240 |
+
return JSONResponse(
|
| 241 |
+
status_code=400,
|
| 242 |
+
content={
|
| 243 |
+
"error": "Missing required fields: scenario_name, scenario_type"
|
| 244 |
+
}
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
# Create scenario based on type
|
| 248 |
+
scenario = _create_risk_scenario(scenario_name, scenario_type, parameters)
|
| 249 |
+
|
| 250 |
+
return JSONResponse(
|
| 251 |
+
status_code=200,
|
| 252 |
+
content={
|
| 253 |
+
"scenario": scenario,
|
| 254 |
+
"message": f"Risk scenario '{scenario_name}' created successfully"
|
| 255 |
+
}
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
except Exception as e:
|
| 259 |
+
logger.error(f"Failed to create risk scenario: {e}")
|
| 260 |
+
return JSONResponse(
|
| 261 |
+
status_code=500,
|
| 262 |
+
content={
|
| 263 |
+
"error": "Failed to create risk scenario",
|
| 264 |
+
"reason": str(e)
|
| 265 |
+
}
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def _create_risk_scenario(scenario_name: str, scenario_type: str, parameters: Dict[str, Any]) -> Dict[str, Any]:
|
| 270 |
+
"""Create a predefined risk scenario."""
|
| 271 |
+
scenarios = {
|
| 272 |
+
"admin_privilege_escalation": {
|
| 273 |
+
"action_context": {
|
| 274 |
+
"action_id": f"admin_escalation_{scenario_name}",
|
| 275 |
+
"actor_id": parameters.get("actor_type", "guest_user"),
|
| 276 |
+
"action_type": "admin_access",
|
| 277 |
+
"resource_id": parameters.get("target_resource", "admin_panel"),
|
| 278 |
+
"metadata": {
|
| 279 |
+
"privilege_level": "admin",
|
| 280 |
+
"escalation_attempt": True
|
| 281 |
+
}
|
| 282 |
+
},
|
| 283 |
+
"expected_decision": "halt",
|
| 284 |
+
"expected_risk_level": "high"
|
| 285 |
+
},
|
| 286 |
+
"suspicious_activity": {
|
| 287 |
+
"action_context": {
|
| 288 |
+
"action_id": f"suspicious_{scenario_name}",
|
| 289 |
+
"actor_id": "suspicious_user",
|
| 290 |
+
"action_type": "data_access",
|
| 291 |
+
"resource_id": "sensitive_data",
|
| 292 |
+
"metadata": {
|
| 293 |
+
"access_pattern": "unusual",
|
| 294 |
+
"time_of_day": "off_hours"
|
| 295 |
+
}
|
| 296 |
+
},
|
| 297 |
+
"expected_decision": "escalate",
|
| 298 |
+
"expected_risk_level": "medium"
|
| 299 |
+
},
|
| 300 |
+
"integrity_violation": {
|
| 301 |
+
"action_context": {
|
| 302 |
+
"action_id": f"integrity_{scenario_name}",
|
| 303 |
+
"actor_id": "system",
|
| 304 |
+
"action_type": "ledger_modification",
|
| 305 |
+
"resource_id": "provenance_ledger",
|
| 306 |
+
"metadata": {
|
| 307 |
+
"modification_type": "unauthorized",
|
| 308 |
+
"integrity_check": "failed"
|
| 309 |
+
}
|
| 310 |
+
},
|
| 311 |
+
"expected_decision": "halt",
|
| 312 |
+
"expected_risk_level": "critical"
|
| 313 |
+
},
|
| 314 |
+
"rate_limit_exceeded": {
|
| 315 |
+
"action_context": {
|
| 316 |
+
"action_id": f"rate_limit_{scenario_name}",
|
| 317 |
+
"actor_id": "high_frequency_user",
|
| 318 |
+
"action_type": "api_call",
|
| 319 |
+
"resource_id": "api_endpoint",
|
| 320 |
+
"metadata": {
|
| 321 |
+
"request_count": 1000,
|
| 322 |
+
"time_window": "1_minute"
|
| 323 |
+
}
|
| 324 |
+
},
|
| 325 |
+
"expected_decision": "escalate",
|
| 326 |
+
"expected_risk_level": "medium"
|
| 327 |
+
}
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
return scenarios.get(scenario_name, {
|
| 331 |
+
"action_context": {
|
| 332 |
+
"action_id": f"custom_{scenario_name}",
|
| 333 |
+
"actor_id": "test_user",
|
| 334 |
+
"action_type": "custom_action",
|
| 335 |
+
"resource_id": "test_resource",
|
| 336 |
+
"metadata": parameters
|
| 337 |
+
},
|
| 338 |
+
"expected_decision": "monitor",
|
| 339 |
+
"expected_risk_level": "low"
|
| 340 |
+
})
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def create_reflexive_routes(reflexive_engine: ReflexiveEngine) -> List[Route]:
|
| 344 |
+
"""Create reflexive core routes.
|
| 345 |
+
|
| 346 |
+
Args:
|
| 347 |
+
reflexive_engine: The reflexive engine instance
|
| 348 |
+
|
| 349 |
+
Returns:
|
| 350 |
+
List of Starlette Route objects for reflexive core management
|
| 351 |
+
"""
|
| 352 |
+
def endpoint_with_engine(endpoint_func):
|
| 353 |
+
async def wrapper(request: Request) -> JSONResponse:
|
| 354 |
+
# Store reflexive engine in app state for access in endpoint
|
| 355 |
+
request.app.state.reflexive_engine = reflexive_engine
|
| 356 |
+
return await endpoint_func(request)
|
| 357 |
+
return wrapper
|
| 358 |
+
|
| 359 |
+
return [
|
| 360 |
+
Route(
|
| 361 |
+
path="/core/simulate-risk",
|
| 362 |
+
endpoint=endpoint_with_engine(simulate_risk_endpoint),
|
| 363 |
+
methods=["POST"]
|
| 364 |
+
),
|
| 365 |
+
Route(
|
| 366 |
+
path="/core/status",
|
| 367 |
+
endpoint=endpoint_with_engine(get_engine_status_endpoint),
|
| 368 |
+
methods=["GET"]
|
| 369 |
+
),
|
| 370 |
+
Route(
|
| 371 |
+
path="/core/submit-action",
|
| 372 |
+
endpoint=endpoint_with_engine(submit_action_endpoint),
|
| 373 |
+
methods=["POST"]
|
| 374 |
+
),
|
| 375 |
+
Route(
|
| 376 |
+
path="/core/monitor-stats",
|
| 377 |
+
endpoint=endpoint_with_engine(get_monitor_stats_endpoint),
|
| 378 |
+
methods=["GET"]
|
| 379 |
+
),
|
| 380 |
+
Route(
|
| 381 |
+
path="/core/risk-scenario",
|
| 382 |
+
endpoint=endpoint_with_engine(create_risk_scenario_endpoint),
|
| 383 |
+
methods=["POST"]
|
| 384 |
+
)
|
| 385 |
+
]
|
src/fastmcp/server/server.py
CHANGED
|
@@ -64,6 +64,7 @@ from fastmcp.tools import ToolManager
|
|
| 64 |
from fastmcp.tools.tool import FunctionTool, Tool, ToolResult
|
| 65 |
from fastmcp.tools.tool_transform import ToolTransformConfig
|
| 66 |
from fastmcp.policy import PolicyEngine
|
|
|
|
| 67 |
from fastmcp.utilities.cli import log_server_banner
|
| 68 |
from fastmcp.utilities.components import FastMCPComponent
|
| 69 |
from fastmcp.utilities.logging import get_logger
|
|
@@ -176,6 +177,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 176 |
self._additional_http_routes: list[BaseRoute] = []
|
| 177 |
self._mounted_servers: list[MountedServer] = []
|
| 178 |
self._policy_engine: Optional[PolicyEngine] = None
|
|
|
|
| 179 |
self._tool_manager = ToolManager(
|
| 180 |
duplicate_behavior=on_duplicate_tools,
|
| 181 |
mask_error_details=mask_error_details,
|
|
@@ -514,6 +516,14 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 514 |
policy_route = create_policy_evaluate_route(self._policy_engine)
|
| 515 |
routes.append(policy_route)
|
| 516 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
# Recursively get routes from mounted servers
|
| 518 |
for mounted_server in self._mounted_servers:
|
| 519 |
mounted_routes = mounted_server.server._get_additional_http_routes()
|
|
@@ -545,6 +555,34 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 545 |
"""
|
| 546 |
return self._policy_engine
|
| 547 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 548 |
async def _mcp_list_tools(self) -> list[MCPTool]:
|
| 549 |
logger.debug("Handler called: list_tools")
|
| 550 |
|
|
|
|
| 64 |
from fastmcp.tools.tool import FunctionTool, Tool, ToolResult
|
| 65 |
from fastmcp.tools.tool_transform import ToolTransformConfig
|
| 66 |
from fastmcp.policy import PolicyEngine
|
| 67 |
+
from fastmcp.reflexive import ReflexiveEngine
|
| 68 |
from fastmcp.utilities.cli import log_server_banner
|
| 69 |
from fastmcp.utilities.components import FastMCPComponent
|
| 70 |
from fastmcp.utilities.logging import get_logger
|
|
|
|
| 177 |
self._additional_http_routes: list[BaseRoute] = []
|
| 178 |
self._mounted_servers: list[MountedServer] = []
|
| 179 |
self._policy_engine: Optional[PolicyEngine] = None
|
| 180 |
+
self._reflexive_engine: Optional[ReflexiveEngine] = None
|
| 181 |
self._tool_manager = ToolManager(
|
| 182 |
duplicate_behavior=on_duplicate_tools,
|
| 183 |
mask_error_details=mask_error_details,
|
|
|
|
| 516 |
policy_route = create_policy_evaluate_route(self._policy_engine)
|
| 517 |
routes.append(policy_route)
|
| 518 |
|
| 519 |
+
|
| 520 |
+
# Add reflexive core endpoints if reflexive engine is configured
|
| 521 |
+
if self._reflexive_engine is not None:
|
| 522 |
+
from fastmcp.server.reflexive_routes import create_reflexive_routes
|
| 523 |
+
|
| 524 |
+
reflexive_routes = create_reflexive_routes(self._reflexive_engine)
|
| 525 |
+
routes.extend(reflexive_routes)
|
| 526 |
+
|
| 527 |
# Recursively get routes from mounted servers
|
| 528 |
for mounted_server in self._mounted_servers:
|
| 529 |
mounted_routes = mounted_server.server._get_additional_http_routes()
|
|
|
|
| 555 |
"""
|
| 556 |
return self._policy_engine
|
| 557 |
|
| 558 |
+
|
| 559 |
+
def enable_reflexive_core(self, reflexive_engine: Optional[ReflexiveEngine] = None) -> ReflexiveEngine:
|
| 560 |
+
"""Enable the reflexive core for this server.
|
| 561 |
+
|
| 562 |
+
Args:
|
| 563 |
+
reflexive_engine: Optional reflexive engine instance. If None, creates a new one.
|
| 564 |
+
|
| 565 |
+
Returns:
|
| 566 |
+
The reflexive engine instance
|
| 567 |
+
"""
|
| 568 |
+
if reflexive_engine is None:
|
| 569 |
+
reflexive_engine = ReflexiveEngine(
|
| 570 |
+
policy_engine=self._policy_engine,
|
| 571 |
+
ledger=None
|
| 572 |
+
)
|
| 573 |
+
|
| 574 |
+
self._reflexive_engine = reflexive_engine
|
| 575 |
+
logger.info("Reflexive core enabled for server")
|
| 576 |
+
return reflexive_engine
|
| 577 |
+
|
| 578 |
+
def get_reflexive_engine(self) -> Optional[ReflexiveEngine]:
|
| 579 |
+
"""Get the reflexive engine instance.
|
| 580 |
+
|
| 581 |
+
Returns:
|
| 582 |
+
The reflexive engine instance, or None if not enabled
|
| 583 |
+
"""
|
| 584 |
+
return self._reflexive_engine
|
| 585 |
+
|
| 586 |
async def _mcp_list_tools(self) -> list[MCPTool]:
|
| 587 |
logger.debug("Handler called: list_tools")
|
| 588 |
|
tests/reflexive/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the reflexive core module."""
|
tests/reflexive/test_actions.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the reflexive actions."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from unittest.mock import Mock
|
| 6 |
+
|
| 7 |
+
from fastmcp.reflexive.actions import (
|
| 8 |
+
HaltAction, EscalateAction, MonitorAction, AllowAction,
|
| 9 |
+
ActionFactory, ActionExecutor
|
| 10 |
+
)
|
| 11 |
+
from fastmcp.reflexive.engine import ReflexiveDecision, ActionContext, DecisionType, RiskLevel
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class TestHaltAction:
|
| 15 |
+
"""Test the HaltAction class."""
|
| 16 |
+
|
| 17 |
+
@pytest.fixture
|
| 18 |
+
def halt_decision(self):
|
| 19 |
+
"""Create a halt decision for testing."""
|
| 20 |
+
action_context = ActionContext(
|
| 21 |
+
action_id="test_action",
|
| 22 |
+
actor_id="test_user",
|
| 23 |
+
action_type="admin_access"
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
return ReflexiveDecision(
|
| 27 |
+
decision_type=DecisionType.HALT,
|
| 28 |
+
risk_level=RiskLevel.HIGH,
|
| 29 |
+
action_context=action_context,
|
| 30 |
+
reason="Unauthorized admin access attempt"
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
@pytest.fixture
|
| 34 |
+
def halt_action(self, halt_decision):
|
| 35 |
+
"""Create a halt action for testing."""
|
| 36 |
+
return HaltAction(
|
| 37 |
+
decision=halt_decision,
|
| 38 |
+
halt_reason="Security violation detected",
|
| 39 |
+
halt_level="immediate",
|
| 40 |
+
affected_operations=["admin_access", "user_management"]
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
def test_halt_action_creation(self, halt_action, halt_decision):
|
| 44 |
+
"""Test halt action creation."""
|
| 45 |
+
assert halt_action.decision == halt_decision
|
| 46 |
+
assert halt_action.halt_reason == "Security violation detected"
|
| 47 |
+
assert halt_action.halt_level == "immediate"
|
| 48 |
+
assert halt_action.affected_operations == ["admin_access", "user_management"]
|
| 49 |
+
assert halt_action.status == "pending"
|
| 50 |
+
assert halt_action.result is None
|
| 51 |
+
|
| 52 |
+
async def test_halt_action_execution(self, halt_action):
|
| 53 |
+
"""Test halt action execution."""
|
| 54 |
+
result = await halt_action.execute()
|
| 55 |
+
|
| 56 |
+
assert halt_action.status == "completed"
|
| 57 |
+
assert halt_action.result is not None
|
| 58 |
+
assert result["halted_operations"] == ["admin_access", "user_management"]
|
| 59 |
+
assert result["halt_reason"] == "Security violation detected"
|
| 60 |
+
assert result["halt_level"] == "immediate"
|
| 61 |
+
assert "halt_timestamp" in result
|
| 62 |
+
assert "decision_id" in result
|
| 63 |
+
|
| 64 |
+
def test_halt_action_type(self, halt_action):
|
| 65 |
+
"""Test halt action type."""
|
| 66 |
+
assert halt_action.get_action_type() == "halt"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class TestEscalateAction:
|
| 70 |
+
"""Test the EscalateAction class."""
|
| 71 |
+
|
| 72 |
+
@pytest.fixture
|
| 73 |
+
def escalate_decision(self):
|
| 74 |
+
"""Create an escalate decision for testing."""
|
| 75 |
+
action_context = ActionContext(
|
| 76 |
+
action_id="test_action",
|
| 77 |
+
actor_id="test_user",
|
| 78 |
+
action_type="data_access"
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
return ReflexiveDecision(
|
| 82 |
+
decision_type=DecisionType.ESCALATE,
|
| 83 |
+
risk_level=RiskLevel.MEDIUM,
|
| 84 |
+
action_context=action_context,
|
| 85 |
+
reason="Suspicious data access pattern"
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
@pytest.fixture
|
| 89 |
+
def escalate_action(self, escalate_decision):
|
| 90 |
+
"""Create an escalate action for testing."""
|
| 91 |
+
return EscalateAction(
|
| 92 |
+
decision=escalate_decision,
|
| 93 |
+
escalation_target="security_team",
|
| 94 |
+
escalation_priority="high",
|
| 95 |
+
escalation_context={"alert_level": "medium"},
|
| 96 |
+
notification_channels=["email", "slack"]
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
def test_escalate_action_creation(self, escalate_action, escalate_decision):
|
| 100 |
+
"""Test escalate action creation."""
|
| 101 |
+
assert escalate_action.decision == escalate_decision
|
| 102 |
+
assert escalate_action.escalation_target == "security_team"
|
| 103 |
+
assert escalate_action.escalation_priority == "high"
|
| 104 |
+
assert escalate_action.escalation_context == {"alert_level": "medium"}
|
| 105 |
+
assert escalate_action.notification_channels == ["email", "slack"]
|
| 106 |
+
assert escalate_action.status == "pending"
|
| 107 |
+
assert escalate_action.result is None
|
| 108 |
+
|
| 109 |
+
async def test_escalate_action_execution(self, escalate_action):
|
| 110 |
+
"""Test escalate action execution."""
|
| 111 |
+
result = await escalate_action.execute()
|
| 112 |
+
|
| 113 |
+
assert escalate_action.status == "completed"
|
| 114 |
+
assert escalate_action.result is not None
|
| 115 |
+
assert result["escalation_target"] == "security_team"
|
| 116 |
+
assert result["escalation_priority"] == "high"
|
| 117 |
+
assert result["escalation_context"] == {"alert_level": "medium"}
|
| 118 |
+
assert result["notification_channels"] == ["email", "slack"]
|
| 119 |
+
assert "escalation_timestamp" in result
|
| 120 |
+
assert "decision_id" in result
|
| 121 |
+
assert "action_context" in result
|
| 122 |
+
|
| 123 |
+
def test_escalate_action_type(self, escalate_action):
|
| 124 |
+
"""Test escalate action type."""
|
| 125 |
+
assert escalate_action.get_action_type() == "escalate"
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class TestMonitorAction:
|
| 129 |
+
"""Test the MonitorAction class."""
|
| 130 |
+
|
| 131 |
+
@pytest.fixture
|
| 132 |
+
def monitor_decision(self):
|
| 133 |
+
"""Create a monitor decision for testing."""
|
| 134 |
+
action_context = ActionContext(
|
| 135 |
+
action_id="test_action",
|
| 136 |
+
actor_id="test_user",
|
| 137 |
+
action_type="api_call"
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
return ReflexiveDecision(
|
| 141 |
+
decision_type=DecisionType.MONITOR,
|
| 142 |
+
risk_level=RiskLevel.LOW,
|
| 143 |
+
action_context=action_context,
|
| 144 |
+
reason="Unusual but not suspicious activity"
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
@pytest.fixture
|
| 148 |
+
def monitor_action(self, monitor_decision):
|
| 149 |
+
"""Create a monitor action for testing."""
|
| 150 |
+
return MonitorAction(
|
| 151 |
+
decision=monitor_decision,
|
| 152 |
+
monitoring_level="enhanced",
|
| 153 |
+
monitoring_duration=1800,
|
| 154 |
+
monitoring_scope=["test_user", "api_calls"]
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
def test_monitor_action_creation(self, monitor_action, monitor_decision):
|
| 158 |
+
"""Test monitor action creation."""
|
| 159 |
+
assert monitor_action.decision == monitor_decision
|
| 160 |
+
assert monitor_action.monitoring_level == "enhanced"
|
| 161 |
+
assert monitor_action.monitoring_duration == 1800
|
| 162 |
+
assert monitor_action.monitoring_scope == ["test_user", "api_calls"]
|
| 163 |
+
assert monitor_action.status == "pending"
|
| 164 |
+
assert monitor_action.result is None
|
| 165 |
+
|
| 166 |
+
async def test_monitor_action_execution(self, monitor_action):
|
| 167 |
+
"""Test monitor action execution."""
|
| 168 |
+
result = await monitor_action.execute()
|
| 169 |
+
|
| 170 |
+
assert monitor_action.status == "completed"
|
| 171 |
+
assert monitor_action.result is not None
|
| 172 |
+
assert result["monitoring_level"] == "enhanced"
|
| 173 |
+
assert result["monitoring_duration"] == 1800
|
| 174 |
+
assert result["monitoring_scope"] == ["test_user", "api_calls"]
|
| 175 |
+
assert "monitoring_timestamp" in result
|
| 176 |
+
assert "decision_id" in result
|
| 177 |
+
assert "action_context" in result
|
| 178 |
+
|
| 179 |
+
def test_monitor_action_type(self, monitor_action):
|
| 180 |
+
"""Test monitor action type."""
|
| 181 |
+
assert monitor_action.get_action_type() == "monitor"
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
class TestAllowAction:
|
| 185 |
+
"""Test the AllowAction class."""
|
| 186 |
+
|
| 187 |
+
@pytest.fixture
|
| 188 |
+
def allow_decision(self):
|
| 189 |
+
"""Create an allow decision for testing."""
|
| 190 |
+
action_context = ActionContext(
|
| 191 |
+
action_id="test_action",
|
| 192 |
+
actor_id="test_user",
|
| 193 |
+
action_type="normal_operation"
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
return ReflexiveDecision(
|
| 197 |
+
decision_type=DecisionType.ALLOW,
|
| 198 |
+
risk_level=RiskLevel.LOW,
|
| 199 |
+
action_context=action_context,
|
| 200 |
+
reason="No violations or anomalies detected"
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
@pytest.fixture
|
| 204 |
+
def allow_action(self, allow_decision):
|
| 205 |
+
"""Create an allow action for testing."""
|
| 206 |
+
return AllowAction(
|
| 207 |
+
decision=allow_decision,
|
| 208 |
+
allow_conditions=["authenticated", "authorized"],
|
| 209 |
+
allow_restrictions=["rate_limited"]
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
def test_allow_action_creation(self, allow_action, allow_decision):
|
| 213 |
+
"""Test allow action creation."""
|
| 214 |
+
assert allow_action.decision == allow_decision
|
| 215 |
+
assert allow_action.allow_conditions == ["authenticated", "authorized"]
|
| 216 |
+
assert allow_action.allow_restrictions == ["rate_limited"]
|
| 217 |
+
assert allow_action.status == "pending"
|
| 218 |
+
assert allow_action.result is None
|
| 219 |
+
|
| 220 |
+
async def test_allow_action_execution(self, allow_action):
|
| 221 |
+
"""Test allow action execution."""
|
| 222 |
+
result = await allow_action.execute()
|
| 223 |
+
|
| 224 |
+
assert allow_action.status == "completed"
|
| 225 |
+
assert allow_action.result is not None
|
| 226 |
+
assert result["allowed"] is True
|
| 227 |
+
assert result["allow_conditions"] == ["authenticated", "authorized"]
|
| 228 |
+
assert result["allow_restrictions"] == ["rate_limited"]
|
| 229 |
+
assert "allow_timestamp" in result
|
| 230 |
+
assert "decision_id" in result
|
| 231 |
+
assert "action_context" in result
|
| 232 |
+
|
| 233 |
+
def test_allow_action_type(self, allow_action):
|
| 234 |
+
"""Test allow action type."""
|
| 235 |
+
assert allow_action.get_action_type() == "allow"
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
class TestActionFactory:
|
| 239 |
+
"""Test the ActionFactory class."""
|
| 240 |
+
|
| 241 |
+
@pytest.fixture
|
| 242 |
+
def halt_decision(self):
|
| 243 |
+
"""Create a halt decision for testing."""
|
| 244 |
+
action_context = ActionContext(
|
| 245 |
+
action_id="test_action",
|
| 246 |
+
actor_id="test_user",
|
| 247 |
+
action_type="admin_access"
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
return ReflexiveDecision(
|
| 251 |
+
decision_type=DecisionType.HALT,
|
| 252 |
+
risk_level=RiskLevel.HIGH,
|
| 253 |
+
action_context=action_context,
|
| 254 |
+
reason="Security violation"
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
@pytest.fixture
|
| 258 |
+
def escalate_decision(self):
|
| 259 |
+
"""Create an escalate decision for testing."""
|
| 260 |
+
action_context = ActionContext(
|
| 261 |
+
action_id="test_action",
|
| 262 |
+
actor_id="test_user",
|
| 263 |
+
action_type="data_access"
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
return ReflexiveDecision(
|
| 267 |
+
decision_type=DecisionType.ESCALATE,
|
| 268 |
+
risk_level=RiskLevel.MEDIUM,
|
| 269 |
+
action_context=action_context,
|
| 270 |
+
reason="Suspicious activity"
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
@pytest.fixture
|
| 274 |
+
def monitor_decision(self):
|
| 275 |
+
"""Create a monitor decision for testing."""
|
| 276 |
+
action_context = ActionContext(
|
| 277 |
+
action_id="test_action",
|
| 278 |
+
actor_id="test_user",
|
| 279 |
+
action_type="api_call"
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
return ReflexiveDecision(
|
| 283 |
+
decision_type=DecisionType.MONITOR,
|
| 284 |
+
risk_level=RiskLevel.LOW,
|
| 285 |
+
action_context=action_context,
|
| 286 |
+
reason="Unusual activity"
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
@pytest.fixture
|
| 290 |
+
def allow_decision(self):
|
| 291 |
+
"""Create an allow decision for testing."""
|
| 292 |
+
action_context = ActionContext(
|
| 293 |
+
action_id="test_action",
|
| 294 |
+
actor_id="test_user",
|
| 295 |
+
action_type="normal_operation"
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
return ReflexiveDecision(
|
| 299 |
+
decision_type=DecisionType.ALLOW,
|
| 300 |
+
risk_level=RiskLevel.LOW,
|
| 301 |
+
action_context=action_context,
|
| 302 |
+
reason="No issues detected"
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
def test_create_halt_action(self, halt_decision):
|
| 306 |
+
"""Test creating a halt action."""
|
| 307 |
+
action = ActionFactory.create_action(halt_decision)
|
| 308 |
+
|
| 309 |
+
assert isinstance(action, HaltAction)
|
| 310 |
+
assert action.decision == halt_decision
|
| 311 |
+
assert action.halt_reason == halt_decision.reason
|
| 312 |
+
assert action.halt_level == "immediate"
|
| 313 |
+
assert halt_decision.action_context.action_id in action.affected_operations
|
| 314 |
+
|
| 315 |
+
def test_create_escalate_action(self, escalate_decision):
|
| 316 |
+
"""Test creating an escalate action."""
|
| 317 |
+
action = ActionFactory.create_action(escalate_decision)
|
| 318 |
+
|
| 319 |
+
assert isinstance(action, EscalateAction)
|
| 320 |
+
assert action.decision == escalate_decision
|
| 321 |
+
assert action.escalation_target == "default_admin"
|
| 322 |
+
assert action.escalation_priority == "normal"
|
| 323 |
+
|
| 324 |
+
def test_create_monitor_action(self, monitor_decision):
|
| 325 |
+
"""Test creating a monitor action."""
|
| 326 |
+
action = ActionFactory.create_action(monitor_decision)
|
| 327 |
+
|
| 328 |
+
assert isinstance(action, MonitorAction)
|
| 329 |
+
assert action.decision == monitor_decision
|
| 330 |
+
assert action.monitoring_level == "enhanced"
|
| 331 |
+
assert action.monitoring_duration == 3600
|
| 332 |
+
|
| 333 |
+
def test_create_allow_action(self, allow_decision):
|
| 334 |
+
"""Test creating an allow action."""
|
| 335 |
+
action = ActionFactory.create_action(allow_decision)
|
| 336 |
+
|
| 337 |
+
assert isinstance(action, AllowAction)
|
| 338 |
+
assert action.decision == allow_decision
|
| 339 |
+
assert action.allow_conditions == []
|
| 340 |
+
assert action.allow_restrictions == []
|
| 341 |
+
|
| 342 |
+
def test_create_action_with_kwargs(self, halt_decision):
|
| 343 |
+
"""Test creating an action with additional kwargs."""
|
| 344 |
+
action = ActionFactory.create_action(
|
| 345 |
+
halt_decision,
|
| 346 |
+
halt_level="graceful",
|
| 347 |
+
affected_operations=["operation1", "operation2"]
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
assert isinstance(action, HaltAction)
|
| 351 |
+
assert action.halt_level == "graceful"
|
| 352 |
+
assert action.affected_operations == ["operation1", "operation2"]
|
| 353 |
+
|
| 354 |
+
def test_create_action_unknown_type(self, halt_decision):
|
| 355 |
+
"""Test creating an action with unknown decision type."""
|
| 356 |
+
halt_decision.decision_type = "unknown_type" # type: ignore
|
| 357 |
+
|
| 358 |
+
with pytest.raises(ValueError, match="Unknown decision type"):
|
| 359 |
+
ActionFactory.create_action(halt_decision)
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
class TestActionExecutor:
|
| 363 |
+
"""Test the ActionExecutor class."""
|
| 364 |
+
|
| 365 |
+
@pytest.fixture
|
| 366 |
+
def action_executor(self):
|
| 367 |
+
"""Create an action executor for testing."""
|
| 368 |
+
return ActionExecutor()
|
| 369 |
+
|
| 370 |
+
@pytest.fixture
|
| 371 |
+
def halt_action(self):
|
| 372 |
+
"""Create a halt action for testing."""
|
| 373 |
+
action_context = ActionContext(
|
| 374 |
+
action_id="test_action",
|
| 375 |
+
actor_id="test_user",
|
| 376 |
+
action_type="admin_access"
|
| 377 |
+
)
|
| 378 |
+
|
| 379 |
+
decision = ReflexiveDecision(
|
| 380 |
+
decision_type=DecisionType.HALT,
|
| 381 |
+
risk_level=RiskLevel.HIGH,
|
| 382 |
+
action_context=action_context,
|
| 383 |
+
reason="Security violation"
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
return HaltAction(
|
| 387 |
+
decision=decision,
|
| 388 |
+
halt_reason="Unauthorized access",
|
| 389 |
+
halt_level="immediate",
|
| 390 |
+
affected_operations=[action_context.action_id]
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
async def test_execute_action_success(self, action_executor, halt_action):
|
| 394 |
+
"""Test successful action execution."""
|
| 395 |
+
result = await action_executor.execute_action(halt_action)
|
| 396 |
+
|
| 397 |
+
assert result["halted_operations"] == [halt_action.decision.action_context.action_id]
|
| 398 |
+
assert result["halt_reason"] == "Unauthorized access"
|
| 399 |
+
assert result["halt_level"] == "immediate"
|
| 400 |
+
|
| 401 |
+
# Check execution history
|
| 402 |
+
assert len(action_executor.execution_history) == 1
|
| 403 |
+
history_record = action_executor.execution_history[0]
|
| 404 |
+
assert history_record["action_id"] == str(halt_action.action_id)
|
| 405 |
+
assert history_record["action_type"] == "halt"
|
| 406 |
+
assert history_record["status"] == "completed"
|
| 407 |
+
assert "end_time" in history_record
|
| 408 |
+
assert "result" in history_record
|
| 409 |
+
|
| 410 |
+
async def test_execute_action_failure(self, action_executor):
|
| 411 |
+
"""Test action execution failure."""
|
| 412 |
+
# Create a mock action that raises an exception
|
| 413 |
+
mock_action = Mock()
|
| 414 |
+
mock_action.action_id = "test_action_id"
|
| 415 |
+
mock_action.decision = Mock()
|
| 416 |
+
mock_action.decision.decision_id = "test_decision_id"
|
| 417 |
+
mock_action.execute.side_effect = Exception("Execution failed")
|
| 418 |
+
mock_action.get_action_type.return_value = "test_type"
|
| 419 |
+
mock_action.timestamp = datetime.utcnow()
|
| 420 |
+
mock_action.status = "pending"
|
| 421 |
+
|
| 422 |
+
with pytest.raises(Exception, match="Execution failed"):
|
| 423 |
+
await action_executor.execute_action(mock_action)
|
| 424 |
+
|
| 425 |
+
# Check execution history
|
| 426 |
+
assert len(action_executor.execution_history) == 1
|
| 427 |
+
history_record = action_executor.execution_history[0]
|
| 428 |
+
assert history_record["action_id"] == "test_action_id"
|
| 429 |
+
assert history_record["status"] == "failed"
|
| 430 |
+
assert "error" in history_record
|
| 431 |
+
assert history_record["error"] == "Execution failed"
|
| 432 |
+
|
| 433 |
+
def test_get_execution_stats(self, action_executor):
|
| 434 |
+
"""Test getting execution statistics."""
|
| 435 |
+
# Add some execution history
|
| 436 |
+
action_executor.execution_history = [
|
| 437 |
+
{"action_id": "1", "status": "completed"},
|
| 438 |
+
{"action_id": "2", "status": "completed"},
|
| 439 |
+
{"action_id": "3", "status": "failed"},
|
| 440 |
+
]
|
| 441 |
+
|
| 442 |
+
stats = action_executor.get_execution_stats()
|
| 443 |
+
|
| 444 |
+
assert stats["total_actions"] == 3
|
| 445 |
+
assert stats["completed_actions"] == 2
|
| 446 |
+
assert stats["failed_actions"] == 1
|
| 447 |
+
assert stats["active_actions"] == 0
|
| 448 |
+
assert stats["success_rate"] == 2/3
|
| 449 |
+
|
| 450 |
+
def test_active_actions_tracking(self, action_executor, halt_action):
|
| 451 |
+
"""Test active actions tracking."""
|
| 452 |
+
# Start execution (this would normally be async)
|
| 453 |
+
action_executor.active_actions[str(halt_action.action_id)] = halt_action
|
| 454 |
+
|
| 455 |
+
assert len(action_executor.active_actions) == 1
|
| 456 |
+
assert str(halt_action.action_id) in action_executor.active_actions
|
| 457 |
+
|
| 458 |
+
# Simulate completion
|
| 459 |
+
del action_executor.active_actions[str(halt_action.action_id)]
|
| 460 |
+
|
| 461 |
+
assert len(action_executor.active_actions) == 0
|
tests/reflexive/test_monitors.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the reflexive monitors."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from unittest.mock import Mock
|
| 6 |
+
|
| 7 |
+
from fastmcp.reflexive.monitor import PolicyMonitor, LedgerMonitor, AnomalyDetector
|
| 8 |
+
from fastmcp.reflexive.engine import ActionContext
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class TestPolicyMonitor:
|
| 12 |
+
"""Test the PolicyMonitor class."""
|
| 13 |
+
|
| 14 |
+
@pytest.fixture
|
| 15 |
+
def policy_monitor(self):
|
| 16 |
+
"""Create a policy monitor for testing."""
|
| 17 |
+
return PolicyMonitor()
|
| 18 |
+
|
| 19 |
+
@pytest.fixture
|
| 20 |
+
def action_context(self):
|
| 21 |
+
"""Create an action context for testing."""
|
| 22 |
+
return ActionContext(
|
| 23 |
+
action_id="test_action",
|
| 24 |
+
actor_id="test_user",
|
| 25 |
+
action_type="tool_call",
|
| 26 |
+
resource_id="test_resource"
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
async def test_monitor_no_violations(self, policy_monitor, action_context):
|
| 30 |
+
"""Test monitoring with no violations."""
|
| 31 |
+
result = await policy_monitor(action_context)
|
| 32 |
+
assert result is None
|
| 33 |
+
|
| 34 |
+
async def test_monitor_admin_access_violation(self, policy_monitor, action_context):
|
| 35 |
+
"""Test monitoring admin access violation."""
|
| 36 |
+
# Create action context with admin access by guest user
|
| 37 |
+
admin_context = ActionContext(
|
| 38 |
+
action_id="admin_action",
|
| 39 |
+
actor_id="guest_user",
|
| 40 |
+
action_type="admin_access",
|
| 41 |
+
resource_id="admin_panel"
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
result = await policy_monitor(admin_context)
|
| 45 |
+
|
| 46 |
+
assert result is not None
|
| 47 |
+
assert result["type"] == "violation"
|
| 48 |
+
assert result["severity"] == "high"
|
| 49 |
+
assert len(result["violations"]) == 1
|
| 50 |
+
assert result["violations"][0]["rule"] == "admin_access_restriction"
|
| 51 |
+
|
| 52 |
+
async def test_monitor_rate_limit_violation(self, policy_monitor, action_context):
|
| 53 |
+
"""Test monitoring rate limit violation."""
|
| 54 |
+
# Add multiple violations for the same actor
|
| 55 |
+
for i in range(5):
|
| 56 |
+
violation_context = ActionContext(
|
| 57 |
+
action_id=f"action_{i}",
|
| 58 |
+
actor_id="rate_limit_user",
|
| 59 |
+
action_type="api_call"
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
# Manually add violations to history
|
| 63 |
+
policy_monitor.violation_history.append({
|
| 64 |
+
"type": "violation",
|
| 65 |
+
"severity": "low",
|
| 66 |
+
"actor_id": "rate_limit_user",
|
| 67 |
+
"action_id": f"action_{i}",
|
| 68 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 69 |
+
})
|
| 70 |
+
|
| 71 |
+
# Test rate limit violation
|
| 72 |
+
result = await policy_monitor(action_context)
|
| 73 |
+
|
| 74 |
+
# Should not trigger rate limit for different actor
|
| 75 |
+
assert result is None
|
| 76 |
+
|
| 77 |
+
# Test with same actor
|
| 78 |
+
rate_limit_context = ActionContext(
|
| 79 |
+
action_id="rate_limit_action",
|
| 80 |
+
actor_id="rate_limit_user",
|
| 81 |
+
action_type="api_call"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
result = await policy_monitor(rate_limit_context)
|
| 85 |
+
|
| 86 |
+
assert result is not None
|
| 87 |
+
assert result["type"] == "violation"
|
| 88 |
+
assert result["severity"] == "medium"
|
| 89 |
+
assert len(result["violations"]) == 1
|
| 90 |
+
assert result["violations"][0]["rule"] == "rate_limit_exceeded"
|
| 91 |
+
|
| 92 |
+
async def test_monitor_sensitive_resource_violation(self, policy_monitor, action_context):
|
| 93 |
+
"""Test monitoring sensitive resource access violation."""
|
| 94 |
+
sensitive_context = ActionContext(
|
| 95 |
+
action_id="sensitive_action",
|
| 96 |
+
actor_id="unauthorized_user",
|
| 97 |
+
action_type="data_access",
|
| 98 |
+
resource_id="sensitive_data",
|
| 99 |
+
metadata={"authorized": False}
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
result = await policy_monitor(sensitive_context)
|
| 103 |
+
|
| 104 |
+
assert result is not None
|
| 105 |
+
assert result["type"] == "violation"
|
| 106 |
+
assert result["severity"] == "critical"
|
| 107 |
+
assert len(result["violations"]) == 1
|
| 108 |
+
assert result["violations"][0]["rule"] == "unauthorized_sensitive_access"
|
| 109 |
+
|
| 110 |
+
def test_assess_violation_severity(self, policy_monitor):
|
| 111 |
+
"""Test violation severity assessment."""
|
| 112 |
+
# Test critical severity
|
| 113 |
+
critical_violations = [{"severity": "critical"}]
|
| 114 |
+
assert policy_monitor._assess_violation_severity(critical_violations) == "critical"
|
| 115 |
+
|
| 116 |
+
# Test high severity
|
| 117 |
+
high_violations = [{"severity": "high"}]
|
| 118 |
+
assert policy_monitor._assess_violation_severity(high_violations) == "high"
|
| 119 |
+
|
| 120 |
+
# Test medium severity
|
| 121 |
+
medium_violations = [{"severity": "medium"}]
|
| 122 |
+
assert policy_monitor._assess_violation_severity(medium_violations) == "medium"
|
| 123 |
+
|
| 124 |
+
# Test low severity
|
| 125 |
+
low_violations = [{"severity": "low"}]
|
| 126 |
+
assert policy_monitor._assess_violation_severity(low_violations) == "low"
|
| 127 |
+
|
| 128 |
+
# Test mixed severities
|
| 129 |
+
mixed_violations = [{"severity": "low"}, {"severity": "high"}]
|
| 130 |
+
assert policy_monitor._assess_violation_severity(mixed_violations) == "high"
|
| 131 |
+
|
| 132 |
+
def test_get_violation_stats(self, policy_monitor):
|
| 133 |
+
"""Test getting violation statistics."""
|
| 134 |
+
# Add some violations
|
| 135 |
+
policy_monitor.violation_history.append({
|
| 136 |
+
"type": "violation",
|
| 137 |
+
"severity": "high",
|
| 138 |
+
"actor_id": "user1",
|
| 139 |
+
"action_id": "action1",
|
| 140 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 141 |
+
})
|
| 142 |
+
|
| 143 |
+
policy_monitor.actor_violations["user1"] = 1
|
| 144 |
+
policy_monitor.actor_violations["user2"] = 2
|
| 145 |
+
|
| 146 |
+
stats = policy_monitor.get_violation_stats()
|
| 147 |
+
|
| 148 |
+
assert stats["total_violations"] == 1
|
| 149 |
+
assert stats["actor_violations"]["user1"] == 1
|
| 150 |
+
assert stats["actor_violations"]["user2"] == 2
|
| 151 |
+
assert "recent_violations" in stats
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
class TestLedgerMonitor:
|
| 155 |
+
"""Test the LedgerMonitor class."""
|
| 156 |
+
|
| 157 |
+
@pytest.fixture
|
| 158 |
+
def ledger_monitor(self):
|
| 159 |
+
"""Create a ledger monitor for testing."""
|
| 160 |
+
return LedgerMonitor()
|
| 161 |
+
|
| 162 |
+
@pytest.fixture
|
| 163 |
+
def mock_ledger(self):
|
| 164 |
+
"""Create a mock ledger for testing."""
|
| 165 |
+
mock_ledger = Mock()
|
| 166 |
+
mock_ledger.verify_chain_integrity.return_value = True
|
| 167 |
+
mock_ledger.get_ledger_statistics.return_value = {
|
| 168 |
+
"total_entries": 10,
|
| 169 |
+
"total_blocks": 2
|
| 170 |
+
}
|
| 171 |
+
return mock_ledger
|
| 172 |
+
|
| 173 |
+
@pytest.fixture
|
| 174 |
+
def action_context(self):
|
| 175 |
+
"""Create an action context for testing."""
|
| 176 |
+
return ActionContext(
|
| 177 |
+
action_id="test_action",
|
| 178 |
+
actor_id="test_user",
|
| 179 |
+
action_type="tool_call"
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
async def test_monitor_no_issues(self, ledger_monitor, mock_ledger, action_context):
|
| 183 |
+
"""Test monitoring with no ledger issues."""
|
| 184 |
+
ledger_monitor.ledger = mock_ledger
|
| 185 |
+
|
| 186 |
+
result = await ledger_monitor(action_context)
|
| 187 |
+
assert result is None
|
| 188 |
+
|
| 189 |
+
async def test_monitor_chain_integrity_issue(self, ledger_monitor, mock_ledger, action_context):
|
| 190 |
+
"""Test monitoring chain integrity issue."""
|
| 191 |
+
# Mock chain integrity failure
|
| 192 |
+
mock_ledger.verify_chain_integrity.return_value = False
|
| 193 |
+
ledger_monitor.ledger = mock_ledger
|
| 194 |
+
|
| 195 |
+
result = await ledger_monitor(action_context)
|
| 196 |
+
|
| 197 |
+
assert result is not None
|
| 198 |
+
assert result["type"] == "anomaly"
|
| 199 |
+
assert result["severity"] == "critical"
|
| 200 |
+
assert len(result["issues"]) == 1
|
| 201 |
+
assert result["issues"][0]["type"] == "chain_integrity"
|
| 202 |
+
|
| 203 |
+
async def test_monitor_missing_blocks_issue(self, ledger_monitor, mock_ledger, action_context):
|
| 204 |
+
"""Test monitoring missing blocks issue."""
|
| 205 |
+
# Mock missing blocks scenario
|
| 206 |
+
mock_ledger.get_ledger_statistics.return_value = {
|
| 207 |
+
"total_entries": 10,
|
| 208 |
+
"total_blocks": 0
|
| 209 |
+
}
|
| 210 |
+
ledger_monitor.ledger = mock_ledger
|
| 211 |
+
|
| 212 |
+
result = await ledger_monitor(action_context)
|
| 213 |
+
|
| 214 |
+
assert result is not None
|
| 215 |
+
assert result["type"] == "anomaly"
|
| 216 |
+
assert result["severity"] == "high"
|
| 217 |
+
assert len(result["issues"]) == 1
|
| 218 |
+
assert result["issues"][0]["type"] == "missing_blocks"
|
| 219 |
+
|
| 220 |
+
async def test_monitor_integrity_check_error(self, ledger_monitor, action_context):
|
| 221 |
+
"""Test monitoring with integrity check error."""
|
| 222 |
+
# Mock ledger that raises exception
|
| 223 |
+
mock_ledger = Mock()
|
| 224 |
+
mock_ledger.verify_chain_integrity.side_effect = Exception("Database error")
|
| 225 |
+
ledger_monitor.ledger = mock_ledger
|
| 226 |
+
|
| 227 |
+
result = await ledger_monitor(action_context)
|
| 228 |
+
|
| 229 |
+
assert result is not None
|
| 230 |
+
assert result["type"] == "anomaly"
|
| 231 |
+
assert result["severity"] == "medium"
|
| 232 |
+
assert len(result["issues"]) == 1
|
| 233 |
+
assert result["issues"][0]["type"] == "integrity_check_error"
|
| 234 |
+
|
| 235 |
+
def test_assess_integrity_severity(self, ledger_monitor):
|
| 236 |
+
"""Test integrity severity assessment."""
|
| 237 |
+
# Test critical severity
|
| 238 |
+
critical_issues = [{"severity": "critical"}]
|
| 239 |
+
assert ledger_monitor._assess_integrity_severity(critical_issues) == "critical"
|
| 240 |
+
|
| 241 |
+
# Test high severity
|
| 242 |
+
high_issues = [{"severity": "high"}]
|
| 243 |
+
assert ledger_monitor._assess_integrity_severity(high_issues) == "high"
|
| 244 |
+
|
| 245 |
+
# Test medium severity
|
| 246 |
+
medium_issues = [{"severity": "medium"}]
|
| 247 |
+
assert ledger_monitor._assess_integrity_severity(medium_issues) == "medium"
|
| 248 |
+
|
| 249 |
+
# Test low severity
|
| 250 |
+
low_issues = [{"severity": "low"}]
|
| 251 |
+
assert ledger_monitor._assess_integrity_severity(low_issues) == "low"
|
| 252 |
+
|
| 253 |
+
def test_get_integrity_stats(self, ledger_monitor):
|
| 254 |
+
"""Test getting integrity statistics."""
|
| 255 |
+
# Add some integrity checks
|
| 256 |
+
ledger_monitor.integrity_checks.append({
|
| 257 |
+
"type": "anomaly",
|
| 258 |
+
"severity": "high",
|
| 259 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 260 |
+
})
|
| 261 |
+
|
| 262 |
+
stats = ledger_monitor.get_integrity_stats()
|
| 263 |
+
|
| 264 |
+
assert stats["total_checks"] == 1
|
| 265 |
+
assert "recent_issues" in stats
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
class TestAnomalyDetector:
|
| 269 |
+
"""Test the AnomalyDetector class."""
|
| 270 |
+
|
| 271 |
+
@pytest.fixture
|
| 272 |
+
def anomaly_detector(self):
|
| 273 |
+
"""Create an anomaly detector for testing."""
|
| 274 |
+
return AnomalyDetector()
|
| 275 |
+
|
| 276 |
+
@pytest.fixture
|
| 277 |
+
def action_context(self):
|
| 278 |
+
"""Create an action context for testing."""
|
| 279 |
+
return ActionContext(
|
| 280 |
+
action_id="test_action",
|
| 281 |
+
actor_id="test_user",
|
| 282 |
+
action_type="tool_call",
|
| 283 |
+
resource_id="test_resource"
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
async def test_detector_no_anomalies(self, anomaly_detector, action_context):
|
| 287 |
+
"""Test detection with no anomalies."""
|
| 288 |
+
# First access to a resource will be flagged as new resource access
|
| 289 |
+
# So we need to access the resource twice to avoid the "new resource" anomaly
|
| 290 |
+
await anomaly_detector(action_context)
|
| 291 |
+
result = await anomaly_detector(action_context)
|
| 292 |
+
assert result is None
|
| 293 |
+
|
| 294 |
+
async def test_detector_high_frequency_anomaly(self, anomaly_detector, action_context):
|
| 295 |
+
"""Test detection of high frequency anomaly."""
|
| 296 |
+
# Add many recent actions for the same actor
|
| 297 |
+
for i in range(25):
|
| 298 |
+
recent_context = ActionContext(
|
| 299 |
+
action_id=f"action_{i}",
|
| 300 |
+
actor_id="high_frequency_user",
|
| 301 |
+
action_type="api_call"
|
| 302 |
+
)
|
| 303 |
+
anomaly_detector._update_patterns(recent_context)
|
| 304 |
+
|
| 305 |
+
# Test high frequency detection
|
| 306 |
+
high_freq_context = ActionContext(
|
| 307 |
+
action_id="high_freq_action",
|
| 308 |
+
actor_id="high_frequency_user",
|
| 309 |
+
action_type="api_call"
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
result = await anomaly_detector(high_freq_context)
|
| 313 |
+
|
| 314 |
+
assert result is not None
|
| 315 |
+
assert result["type"] == "anomaly"
|
| 316 |
+
assert result["severity"] == "medium"
|
| 317 |
+
assert len(result["anomalies"]) == 1
|
| 318 |
+
assert result["anomalies"][0]["type"] == "high_frequency"
|
| 319 |
+
|
| 320 |
+
async def test_detector_unusual_timing_anomaly(self, anomaly_detector, action_context):
|
| 321 |
+
"""Test detection of unusual timing anomaly."""
|
| 322 |
+
# Create action at unusual hour with new action type
|
| 323 |
+
unusual_context = ActionContext(
|
| 324 |
+
action_id="unusual_action",
|
| 325 |
+
actor_id="test_user",
|
| 326 |
+
action_type="new_action_type" # New action type
|
| 327 |
+
)
|
| 328 |
+
unusual_context.timestamp = datetime.utcnow().replace(hour=3) # 3 AM
|
| 329 |
+
|
| 330 |
+
result = await anomaly_detector(unusual_context)
|
| 331 |
+
|
| 332 |
+
assert result is not None
|
| 333 |
+
assert result["type"] == "anomaly"
|
| 334 |
+
assert result["severity"] == "low"
|
| 335 |
+
assert len(result["anomalies"]) == 1
|
| 336 |
+
assert result["anomalies"][0]["type"] == "unusual_timing"
|
| 337 |
+
|
| 338 |
+
async def test_detector_new_resource_access_anomaly(self, anomaly_detector, action_context):
|
| 339 |
+
"""Test detection of new resource access anomaly."""
|
| 340 |
+
# First access to a resource
|
| 341 |
+
new_resource_context = ActionContext(
|
| 342 |
+
action_id="new_resource_action",
|
| 343 |
+
actor_id="test_user",
|
| 344 |
+
action_type="data_access",
|
| 345 |
+
resource_id="new_resource"
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
result = await anomaly_detector(new_resource_context)
|
| 349 |
+
|
| 350 |
+
assert result is not None
|
| 351 |
+
assert result["type"] == "anomaly"
|
| 352 |
+
assert result["severity"] == "low"
|
| 353 |
+
assert len(result["anomalies"]) == 1
|
| 354 |
+
assert result["anomalies"][0]["type"] == "new_resource_access"
|
| 355 |
+
|
| 356 |
+
async def test_detector_privilege_escalation_anomaly(self, anomaly_detector, action_context):
|
| 357 |
+
"""Test detection of privilege escalation anomaly."""
|
| 358 |
+
# First time performing privileged action
|
| 359 |
+
privilege_context = ActionContext(
|
| 360 |
+
action_id="privilege_action",
|
| 361 |
+
actor_id="test_user",
|
| 362 |
+
action_type="admin_access",
|
| 363 |
+
resource_id="admin_panel"
|
| 364 |
+
)
|
| 365 |
+
|
| 366 |
+
result = await anomaly_detector(privilege_context)
|
| 367 |
+
|
| 368 |
+
assert result is not None
|
| 369 |
+
assert result["type"] == "anomaly"
|
| 370 |
+
assert result["severity"] == "high"
|
| 371 |
+
# Should detect both new resource access and privilege escalation
|
| 372 |
+
assert len(result["anomalies"]) >= 1
|
| 373 |
+
# Check that privilege escalation is detected
|
| 374 |
+
privilege_anomalies = [a for a in result["anomalies"] if a["type"] == "privilege_escalation"]
|
| 375 |
+
assert len(privilege_anomalies) == 1
|
| 376 |
+
assert privilege_anomalies[0]["type"] == "privilege_escalation"
|
| 377 |
+
|
| 378 |
+
def test_update_patterns(self, anomaly_detector, action_context):
|
| 379 |
+
"""Test pattern updating."""
|
| 380 |
+
# Update patterns
|
| 381 |
+
anomaly_detector._update_patterns(action_context)
|
| 382 |
+
|
| 383 |
+
# Check that patterns were updated
|
| 384 |
+
actor_data = anomaly_detector.actor_patterns[action_context.actor_id]
|
| 385 |
+
assert actor_data["action_counts"][action_context.action_type] == 1
|
| 386 |
+
assert actor_data["resource_access"][action_context.resource_id] == 1
|
| 387 |
+
assert len(actor_data["session_times"]) == 1
|
| 388 |
+
assert actor_data["last_seen"] == action_context.timestamp
|
| 389 |
+
|
| 390 |
+
# Check global patterns
|
| 391 |
+
assert anomaly_detector.global_patterns["action_frequency"][action_context.action_type] == 1
|
| 392 |
+
assert anomaly_detector.global_patterns["resource_access"][action_context.resource_id] == 1
|
| 393 |
+
|
| 394 |
+
def test_assess_anomaly_severity(self, anomaly_detector):
|
| 395 |
+
"""Test anomaly severity assessment."""
|
| 396 |
+
# Test high severity
|
| 397 |
+
high_anomalies = [{"severity": "high"}]
|
| 398 |
+
assert anomaly_detector._assess_anomaly_severity(high_anomalies) == "high"
|
| 399 |
+
|
| 400 |
+
# Test medium severity
|
| 401 |
+
medium_anomalies = [{"severity": "medium"}]
|
| 402 |
+
assert anomaly_detector._assess_anomaly_severity(medium_anomalies) == "medium"
|
| 403 |
+
|
| 404 |
+
# Test low severity
|
| 405 |
+
low_anomalies = [{"severity": "low"}]
|
| 406 |
+
assert anomaly_detector._assess_anomaly_severity(low_anomalies) == "low"
|
| 407 |
+
|
| 408 |
+
# Test mixed severities
|
| 409 |
+
mixed_anomalies = [{"severity": "low"}, {"severity": "high"}]
|
| 410 |
+
assert anomaly_detector._assess_anomaly_severity(mixed_anomalies) == "high"
|
| 411 |
+
|
| 412 |
+
def test_get_anomaly_stats(self, anomaly_detector, action_context):
|
| 413 |
+
"""Test getting anomaly statistics."""
|
| 414 |
+
# Update patterns for some actors
|
| 415 |
+
anomaly_detector._update_patterns(action_context)
|
| 416 |
+
|
| 417 |
+
another_context = ActionContext(
|
| 418 |
+
action_id="another_action",
|
| 419 |
+
actor_id="another_user",
|
| 420 |
+
action_type="another_type"
|
| 421 |
+
)
|
| 422 |
+
anomaly_detector._update_patterns(another_context)
|
| 423 |
+
|
| 424 |
+
stats = anomaly_detector.get_anomaly_stats()
|
| 425 |
+
|
| 426 |
+
assert stats["tracked_actors"] == 2
|
| 427 |
+
assert stats["global_action_types"] == 2
|
| 428 |
+
assert stats["global_resources"] == 1
|
tests/reflexive/test_reflexive_engine.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the reflexive engine."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from unittest.mock import Mock, AsyncMock
|
| 6 |
+
|
| 7 |
+
from fastmcp.reflexive import ReflexiveEngine, ActionContext, DecisionType, RiskLevel
|
| 8 |
+
from fastmcp.reflexive.engine import ReflexiveDecision
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class TestReflexiveEngine:
|
| 12 |
+
"""Test the reflexive engine functionality."""
|
| 13 |
+
|
| 14 |
+
@pytest.fixture
|
| 15 |
+
def reflexive_engine(self):
|
| 16 |
+
"""Create a reflexive engine for testing."""
|
| 17 |
+
return ReflexiveEngine()
|
| 18 |
+
|
| 19 |
+
@pytest.fixture
|
| 20 |
+
def action_context(self):
|
| 21 |
+
"""Create an action context for testing."""
|
| 22 |
+
return ActionContext(
|
| 23 |
+
action_id="test_action_123",
|
| 24 |
+
actor_id="test_user",
|
| 25 |
+
action_type="tool_call",
|
| 26 |
+
resource_id="test_resource",
|
| 27 |
+
metadata={"test": "data"}
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
def test_engine_initialization(self, reflexive_engine):
|
| 31 |
+
"""Test reflexive engine initialization."""
|
| 32 |
+
assert reflexive_engine.policy_engine is None
|
| 33 |
+
assert reflexive_engine.ledger is None
|
| 34 |
+
assert len(reflexive_engine.monitors) == 0
|
| 35 |
+
assert len(reflexive_engine.decision_handlers) == 4
|
| 36 |
+
assert not reflexive_engine.is_running
|
| 37 |
+
|
| 38 |
+
def test_engine_status(self, reflexive_engine):
|
| 39 |
+
"""Test getting engine status."""
|
| 40 |
+
status = reflexive_engine.get_engine_status()
|
| 41 |
+
|
| 42 |
+
assert "is_running" in status
|
| 43 |
+
assert "monitor_count" in status
|
| 44 |
+
assert "queue_size" in status
|
| 45 |
+
assert "decision_handlers" in status
|
| 46 |
+
|
| 47 |
+
assert status["is_running"] is False
|
| 48 |
+
assert status["monitor_count"] == 0
|
| 49 |
+
assert len(status["decision_handlers"]) == 4
|
| 50 |
+
|
| 51 |
+
def test_add_remove_monitor(self, reflexive_engine):
|
| 52 |
+
"""Test adding and removing monitors."""
|
| 53 |
+
def test_monitor(context):
|
| 54 |
+
return None
|
| 55 |
+
|
| 56 |
+
# Add monitor
|
| 57 |
+
reflexive_engine.add_monitor(test_monitor)
|
| 58 |
+
assert len(reflexive_engine.monitors) == 1
|
| 59 |
+
assert test_monitor in reflexive_engine.monitors
|
| 60 |
+
|
| 61 |
+
# Remove monitor
|
| 62 |
+
reflexive_engine.remove_monitor(test_monitor)
|
| 63 |
+
assert len(reflexive_engine.monitors) == 0
|
| 64 |
+
assert test_monitor not in reflexive_engine.monitors
|
| 65 |
+
|
| 66 |
+
async def test_submit_action(self, reflexive_engine, action_context):
|
| 67 |
+
"""Test submitting an action for evaluation."""
|
| 68 |
+
await reflexive_engine.submit_action(action_context)
|
| 69 |
+
|
| 70 |
+
# Check that action was added to queue
|
| 71 |
+
assert reflexive_engine.event_queue.qsize() == 1
|
| 72 |
+
|
| 73 |
+
async def test_evaluate_action_no_violations(self, reflexive_engine, action_context):
|
| 74 |
+
"""Test evaluating an action with no violations."""
|
| 75 |
+
decision = await reflexive_engine._evaluate_action(action_context)
|
| 76 |
+
|
| 77 |
+
assert decision.decision_type == DecisionType.ALLOW
|
| 78 |
+
assert decision.risk_level == RiskLevel.LOW
|
| 79 |
+
assert decision.reason == "No violations or anomalies detected"
|
| 80 |
+
assert decision.action_context == action_context
|
| 81 |
+
assert decision.proof_hash is not None
|
| 82 |
+
|
| 83 |
+
async def test_evaluate_action_with_violations(self, reflexive_engine, action_context):
|
| 84 |
+
"""Test evaluating an action with violations."""
|
| 85 |
+
# Add a monitor that returns violations
|
| 86 |
+
def violation_monitor(context):
|
| 87 |
+
return {
|
| 88 |
+
"type": "violation",
|
| 89 |
+
"severity": "high",
|
| 90 |
+
"violations": [{"rule": "test_rule", "message": "Test violation", "severity": "high"}]
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
reflexive_engine.add_monitor(violation_monitor)
|
| 94 |
+
|
| 95 |
+
decision = await reflexive_engine._evaluate_action(action_context)
|
| 96 |
+
|
| 97 |
+
assert decision.decision_type == DecisionType.HALT
|
| 98 |
+
assert decision.risk_level == RiskLevel.HIGH
|
| 99 |
+
assert "violations" in decision.evidence
|
| 100 |
+
assert len(decision.evidence["violations"]) == 1
|
| 101 |
+
|
| 102 |
+
async def test_evaluate_action_with_anomalies(self, reflexive_engine, action_context):
|
| 103 |
+
"""Test evaluating an action with anomalies."""
|
| 104 |
+
# Add a monitor that returns anomalies
|
| 105 |
+
def anomaly_monitor(context):
|
| 106 |
+
return {
|
| 107 |
+
"type": "anomaly",
|
| 108 |
+
"severity": "medium",
|
| 109 |
+
"anomalies": [{"type": "test_anomaly", "message": "Test anomaly", "severity": "medium"}]
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
reflexive_engine.add_monitor(anomaly_monitor)
|
| 113 |
+
|
| 114 |
+
decision = await reflexive_engine._evaluate_action(action_context)
|
| 115 |
+
|
| 116 |
+
assert decision.decision_type == DecisionType.ESCALATE
|
| 117 |
+
assert decision.risk_level == RiskLevel.MEDIUM
|
| 118 |
+
assert "anomalies" in decision.evidence
|
| 119 |
+
assert len(decision.evidence["anomalies"]) == 1
|
| 120 |
+
|
| 121 |
+
async def test_evaluate_action_critical_violation(self, reflexive_engine, action_context):
|
| 122 |
+
"""Test evaluating an action with critical violations."""
|
| 123 |
+
# Add a monitor that returns critical violations
|
| 124 |
+
def critical_monitor(context):
|
| 125 |
+
return {
|
| 126 |
+
"type": "violation",
|
| 127 |
+
"severity": "critical",
|
| 128 |
+
"violations": [{"rule": "critical_rule", "message": "Critical violation", "severity": "critical"}]
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
reflexive_engine.add_monitor(critical_monitor)
|
| 132 |
+
|
| 133 |
+
decision = await reflexive_engine._evaluate_action(action_context)
|
| 134 |
+
|
| 135 |
+
assert decision.decision_type == DecisionType.HALT
|
| 136 |
+
assert decision.risk_level == RiskLevel.CRITICAL
|
| 137 |
+
|
| 138 |
+
async def test_simulate_risk(self, reflexive_engine):
|
| 139 |
+
"""Test risk simulation."""
|
| 140 |
+
risk_scenario = {
|
| 141 |
+
"action_context": {
|
| 142 |
+
"action_id": "simulation_action",
|
| 143 |
+
"actor_id": "test_actor",
|
| 144 |
+
"action_type": "admin_access",
|
| 145 |
+
"resource_id": "sensitive_data"
|
| 146 |
+
},
|
| 147 |
+
"monitors": [
|
| 148 |
+
lambda ctx: {
|
| 149 |
+
"type": "violation",
|
| 150 |
+
"severity": "high",
|
| 151 |
+
"violations": [{"rule": "admin_restriction", "message": "Unauthorized admin access", "severity": "high"}]
|
| 152 |
+
}
|
| 153 |
+
]
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
decision = await reflexive_engine.simulate_risk(risk_scenario)
|
| 157 |
+
|
| 158 |
+
assert decision.decision_type == DecisionType.HALT
|
| 159 |
+
assert decision.risk_level == RiskLevel.HIGH
|
| 160 |
+
assert decision.action_context.action_id == "simulation_action"
|
| 161 |
+
|
| 162 |
+
def test_assess_risk_level(self, reflexive_engine):
|
| 163 |
+
"""Test risk level assessment."""
|
| 164 |
+
# Test critical risk
|
| 165 |
+
critical_violations = [{"severity": "critical"}]
|
| 166 |
+
assert reflexive_engine._assess_risk_level(critical_violations, []) == RiskLevel.CRITICAL
|
| 167 |
+
|
| 168 |
+
# Test high risk
|
| 169 |
+
high_violations = [{"severity": "high"}]
|
| 170 |
+
assert reflexive_engine._assess_risk_level(high_violations, []) == RiskLevel.HIGH
|
| 171 |
+
|
| 172 |
+
# Test medium risk
|
| 173 |
+
medium_violations = [{"severity": "medium"}]
|
| 174 |
+
assert reflexive_engine._assess_risk_level(medium_violations, []) == RiskLevel.MEDIUM
|
| 175 |
+
|
| 176 |
+
# Test low risk
|
| 177 |
+
low_violations = [{"severity": "low"}]
|
| 178 |
+
assert reflexive_engine._assess_risk_level(low_violations, []) == RiskLevel.LOW
|
| 179 |
+
|
| 180 |
+
# Test multiple issues
|
| 181 |
+
multiple_issues = [{"severity": "low"}, {"severity": "low"}, {"severity": "low"}]
|
| 182 |
+
assert reflexive_engine._assess_risk_level(multiple_issues, []) == RiskLevel.MEDIUM
|
| 183 |
+
|
| 184 |
+
def test_determine_escalation_target(self, reflexive_engine):
|
| 185 |
+
"""Test escalation target determination."""
|
| 186 |
+
decision = ReflexiveDecision(
|
| 187 |
+
decision_type=DecisionType.ESCALATE,
|
| 188 |
+
risk_level=RiskLevel.CRITICAL,
|
| 189 |
+
action_context=ActionContext(action_id="test", actor_id="test", action_type="test"),
|
| 190 |
+
reason="Test"
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
target = reflexive_engine._determine_escalation_target(decision)
|
| 194 |
+
assert target == "security_admin"
|
| 195 |
+
|
| 196 |
+
decision.risk_level = RiskLevel.HIGH
|
| 197 |
+
target = reflexive_engine._determine_escalation_target(decision)
|
| 198 |
+
assert target == "system_admin"
|
| 199 |
+
|
| 200 |
+
decision.risk_level = RiskLevel.MEDIUM
|
| 201 |
+
target = reflexive_engine._determine_escalation_target(decision)
|
| 202 |
+
assert target == "monitoring_team"
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
class TestActionContext:
|
| 206 |
+
"""Test the ActionContext class."""
|
| 207 |
+
|
| 208 |
+
def test_action_context_creation(self):
|
| 209 |
+
"""Test creating an action context."""
|
| 210 |
+
context = ActionContext(
|
| 211 |
+
action_id="test_action",
|
| 212 |
+
actor_id="test_actor",
|
| 213 |
+
action_type="test_type"
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
assert context.action_id == "test_action"
|
| 217 |
+
assert context.actor_id == "test_actor"
|
| 218 |
+
assert context.action_type == "test_type"
|
| 219 |
+
assert context.resource_id is None
|
| 220 |
+
assert context.metadata == {}
|
| 221 |
+
assert isinstance(context.timestamp, datetime)
|
| 222 |
+
|
| 223 |
+
def test_action_context_hash(self):
|
| 224 |
+
"""Test action context hash generation."""
|
| 225 |
+
context1 = ActionContext(
|
| 226 |
+
action_id="test_action",
|
| 227 |
+
actor_id="test_actor",
|
| 228 |
+
action_type="test_type",
|
| 229 |
+
metadata={"key": "value"}
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
context2 = ActionContext(
|
| 233 |
+
action_id="test_action",
|
| 234 |
+
actor_id="test_actor",
|
| 235 |
+
action_type="test_type",
|
| 236 |
+
metadata={"key": "value"}
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
# Same content should produce same hash
|
| 240 |
+
assert context1.get_context_hash() == context2.get_context_hash()
|
| 241 |
+
|
| 242 |
+
# Different content should produce different hash
|
| 243 |
+
context3 = ActionContext(
|
| 244 |
+
action_id="different_action",
|
| 245 |
+
actor_id="test_actor",
|
| 246 |
+
action_type="test_type",
|
| 247 |
+
metadata={"key": "value"}
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
assert context1.get_context_hash() != context3.get_context_hash()
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
class TestReflexiveDecision:
|
| 254 |
+
"""Test the ReflexiveDecision class."""
|
| 255 |
+
|
| 256 |
+
def test_decision_creation(self):
|
| 257 |
+
"""Test creating a reflexive decision."""
|
| 258 |
+
action_context = ActionContext(
|
| 259 |
+
action_id="test_action",
|
| 260 |
+
actor_id="test_actor",
|
| 261 |
+
action_type="test_type"
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
decision = ReflexiveDecision(
|
| 265 |
+
decision_type=DecisionType.HALT,
|
| 266 |
+
risk_level=RiskLevel.HIGH,
|
| 267 |
+
action_context=action_context,
|
| 268 |
+
reason="Test reason"
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
assert decision.decision_type == DecisionType.HALT
|
| 272 |
+
assert decision.risk_level == RiskLevel.HIGH
|
| 273 |
+
assert decision.action_context == action_context
|
| 274 |
+
assert decision.reason == "Test reason"
|
| 275 |
+
assert decision.evidence == {}
|
| 276 |
+
assert decision.escalated_to is None
|
| 277 |
+
assert decision.proof_hash is None
|
| 278 |
+
assert isinstance(decision.timestamp, datetime)
|
| 279 |
+
|
| 280 |
+
def test_decision_hash(self):
|
| 281 |
+
"""Test decision hash generation."""
|
| 282 |
+
from uuid import uuid4
|
| 283 |
+
|
| 284 |
+
action_context = ActionContext(
|
| 285 |
+
action_id="test_action",
|
| 286 |
+
actor_id="test_actor",
|
| 287 |
+
action_type="test_type"
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
# Use the same decision_id for both decisions
|
| 291 |
+
decision_id = uuid4()
|
| 292 |
+
|
| 293 |
+
decision1 = ReflexiveDecision(
|
| 294 |
+
decision_id=decision_id,
|
| 295 |
+
decision_type=DecisionType.HALT,
|
| 296 |
+
risk_level=RiskLevel.HIGH,
|
| 297 |
+
action_context=action_context,
|
| 298 |
+
reason="Test reason"
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
decision2 = ReflexiveDecision(
|
| 302 |
+
decision_id=decision_id,
|
| 303 |
+
decision_type=DecisionType.HALT,
|
| 304 |
+
risk_level=RiskLevel.HIGH,
|
| 305 |
+
action_context=action_context,
|
| 306 |
+
reason="Test reason"
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
# Same content should produce same hash
|
| 310 |
+
assert decision1.get_decision_hash() == decision2.get_decision_hash()
|
| 311 |
+
|
| 312 |
+
# Different content should produce different hash
|
| 313 |
+
decision3 = ReflexiveDecision(
|
| 314 |
+
decision_type=DecisionType.ESCALATE,
|
| 315 |
+
risk_level=RiskLevel.HIGH,
|
| 316 |
+
action_context=action_context,
|
| 317 |
+
reason="Test reason"
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
assert decision1.get_decision_hash() != decision3.get_decision_hash()
|
tests/reflexive/test_reflexive_http.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the reflexive HTTP endpoints."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
import httpx
|
| 5 |
+
from unittest.mock import Mock, AsyncMock
|
| 6 |
+
|
| 7 |
+
from fastmcp.server.server import FastMCP
|
| 8 |
+
from fastmcp.reflexive import ReflexiveEngine, ActionContext, DecisionType, RiskLevel
|
| 9 |
+
from fastmcp.server.http import create_streamable_http_app
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@pytest.fixture(name="server_with_reflexive")
|
| 13 |
+
async def server_with_reflexive_fixture():
|
| 14 |
+
"""Fixture for a FastMCP server with an enabled reflexive core."""
|
| 15 |
+
server = FastMCP("TestReflexiveServer")
|
| 16 |
+
reflexive_engine = server.enable_reflexive_core()
|
| 17 |
+
return server, reflexive_engine
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@pytest.fixture
|
| 21 |
+
async def client(server_with_reflexive):
|
| 22 |
+
"""Create an HTTP client for testing."""
|
| 23 |
+
server, reflexive_engine = server_with_reflexive
|
| 24 |
+
app = create_streamable_http_app(server, streamable_http_path="/")
|
| 25 |
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
| 26 |
+
# Store server and reflexive engine in client for test access
|
| 27 |
+
client.server = server
|
| 28 |
+
client.reflexive_engine = reflexive_engine
|
| 29 |
+
yield client
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class TestReflexiveHTTPEndpoints:
|
| 33 |
+
"""Test the reflexive HTTP endpoints."""
|
| 34 |
+
|
| 35 |
+
async def test_simulate_risk_endpoint(self, client):
|
| 36 |
+
"""Test the simulate risk endpoint."""
|
| 37 |
+
risk_scenario = {
|
| 38 |
+
"action_context": {
|
| 39 |
+
"action_id": "test_action",
|
| 40 |
+
"actor_id": "test_user",
|
| 41 |
+
"action_type": "admin_access",
|
| 42 |
+
"resource_id": "admin_panel"
|
| 43 |
+
},
|
| 44 |
+
"scenario_type": "policy_violation"
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
response = await client.post("/core/simulate-risk", json=risk_scenario)
|
| 48 |
+
|
| 49 |
+
assert response.status_code == 200
|
| 50 |
+
data = response.json()
|
| 51 |
+
assert "simulation_id" in data
|
| 52 |
+
assert "decision" in data
|
| 53 |
+
assert "action" in data
|
| 54 |
+
assert "action_context" in data
|
| 55 |
+
|
| 56 |
+
# Check decision structure
|
| 57 |
+
decision = data["decision"]
|
| 58 |
+
assert "decision_id" in decision
|
| 59 |
+
assert "decision_type" in decision
|
| 60 |
+
assert "risk_level" in decision
|
| 61 |
+
assert "reason" in decision
|
| 62 |
+
assert "proof_hash" in decision
|
| 63 |
+
|
| 64 |
+
# Check action structure
|
| 65 |
+
action = data["action"]
|
| 66 |
+
assert "action_id" in action
|
| 67 |
+
assert "action_type" in action
|
| 68 |
+
assert "status" in action
|
| 69 |
+
assert "result" in action
|
| 70 |
+
|
| 71 |
+
async def test_simulate_risk_invalid_data(self, client):
|
| 72 |
+
"""Test simulate risk endpoint with invalid data."""
|
| 73 |
+
invalid_scenario = {
|
| 74 |
+
"invalid_field": "invalid_value"
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
response = await client.post("/core/simulate-risk", json=invalid_scenario)
|
| 78 |
+
|
| 79 |
+
assert response.status_code == 400
|
| 80 |
+
data = response.json()
|
| 81 |
+
assert "error" in data
|
| 82 |
+
assert "Missing required field: action_context" in data["error"]
|
| 83 |
+
|
| 84 |
+
async def test_get_engine_status_endpoint(self, client):
|
| 85 |
+
"""Test the get engine status endpoint."""
|
| 86 |
+
response = await client.get("/core/status")
|
| 87 |
+
|
| 88 |
+
assert response.status_code == 200
|
| 89 |
+
data = response.json()
|
| 90 |
+
assert "is_running" in data
|
| 91 |
+
assert "monitor_count" in data
|
| 92 |
+
assert "queue_size" in data
|
| 93 |
+
assert "decision_handlers" in data
|
| 94 |
+
|
| 95 |
+
async def test_submit_action_endpoint(self, client):
|
| 96 |
+
"""Test the submit action endpoint."""
|
| 97 |
+
action_data = {
|
| 98 |
+
"action_id": "test_action",
|
| 99 |
+
"actor_id": "test_user",
|
| 100 |
+
"action_type": "tool_call",
|
| 101 |
+
"resource_id": "test_resource",
|
| 102 |
+
"metadata": {"test": "data"}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
response = await client.post("/core/submit-action", json=action_data)
|
| 106 |
+
|
| 107 |
+
assert response.status_code == 202
|
| 108 |
+
data = response.json()
|
| 109 |
+
assert "message" in data
|
| 110 |
+
assert "action_id" in data
|
| 111 |
+
assert "submitted_at" in data
|
| 112 |
+
assert data["action_id"] == "test_action"
|
| 113 |
+
|
| 114 |
+
async def test_submit_action_invalid_data(self, client):
|
| 115 |
+
"""Test submit action endpoint with invalid data."""
|
| 116 |
+
invalid_action = {
|
| 117 |
+
"invalid_field": "invalid_value"
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
response = await client.post("/core/submit-action", json=invalid_action)
|
| 121 |
+
|
| 122 |
+
assert response.status_code == 400
|
| 123 |
+
data = response.json()
|
| 124 |
+
assert "error" in data
|
| 125 |
+
assert "Invalid action data" in data["error"]
|
| 126 |
+
|
| 127 |
+
async def test_get_monitor_stats_endpoint(self, client):
|
| 128 |
+
"""Test the get monitor stats endpoint."""
|
| 129 |
+
response = await client.get("/core/monitor-stats")
|
| 130 |
+
|
| 131 |
+
assert response.status_code == 200
|
| 132 |
+
data = response.json()
|
| 133 |
+
# Should return empty dict if no monitors are configured
|
| 134 |
+
assert isinstance(data, dict)
|
| 135 |
+
|
| 136 |
+
async def test_create_risk_scenario_endpoint(self, client):
|
| 137 |
+
"""Test the create risk scenario endpoint."""
|
| 138 |
+
scenario_data = {
|
| 139 |
+
"scenario_name": "admin_privilege_escalation",
|
| 140 |
+
"scenario_type": "policy_violation",
|
| 141 |
+
"parameters": {
|
| 142 |
+
"actor_type": "guest_user",
|
| 143 |
+
"target_resource": "admin_panel",
|
| 144 |
+
"severity": "high"
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
response = await client.post("/core/risk-scenario", json=scenario_data)
|
| 149 |
+
|
| 150 |
+
assert response.status_code == 200
|
| 151 |
+
data = response.json()
|
| 152 |
+
assert "scenario" in data
|
| 153 |
+
assert "message" in data
|
| 154 |
+
assert data["message"] == "Risk scenario 'admin_privilege_escalation' created successfully"
|
| 155 |
+
|
| 156 |
+
# Check scenario structure
|
| 157 |
+
scenario = data["scenario"]
|
| 158 |
+
assert "action_context" in scenario
|
| 159 |
+
assert "expected_decision" in scenario
|
| 160 |
+
assert "expected_risk_level" in scenario
|
| 161 |
+
|
| 162 |
+
# Check action context
|
| 163 |
+
action_context = scenario["action_context"]
|
| 164 |
+
assert action_context["action_type"] == "admin_access"
|
| 165 |
+
assert action_context["resource_id"] == "admin_panel"
|
| 166 |
+
|
| 167 |
+
async def test_create_risk_scenario_missing_fields(self, client):
|
| 168 |
+
"""Test create risk scenario endpoint with missing fields."""
|
| 169 |
+
invalid_scenario = {
|
| 170 |
+
"scenario_name": "test_scenario"
|
| 171 |
+
# Missing scenario_type
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
response = await client.post("/core/risk-scenario", json=invalid_scenario)
|
| 175 |
+
|
| 176 |
+
assert response.status_code == 400
|
| 177 |
+
data = response.json()
|
| 178 |
+
assert "error" in data
|
| 179 |
+
assert "Missing required fields" in data["error"]
|
| 180 |
+
|
| 181 |
+
async def test_create_risk_scenario_unknown_type(self, client):
|
| 182 |
+
"""Test create risk scenario endpoint with unknown scenario type."""
|
| 183 |
+
scenario_data = {
|
| 184 |
+
"scenario_name": "unknown_scenario",
|
| 185 |
+
"scenario_type": "unknown_type",
|
| 186 |
+
"parameters": {}
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
response = await client.post("/core/risk-scenario", json=scenario_data)
|
| 190 |
+
|
| 191 |
+
assert response.status_code == 200
|
| 192 |
+
data = response.json()
|
| 193 |
+
assert "scenario" in data
|
| 194 |
+
|
| 195 |
+
# Should create a custom scenario
|
| 196 |
+
scenario = data["scenario"]
|
| 197 |
+
assert scenario["action_context"]["action_id"] == "custom_unknown_scenario"
|
| 198 |
+
assert scenario["expected_decision"] == "monitor"
|
| 199 |
+
assert scenario["expected_risk_level"] == "low"
|
| 200 |
+
|
| 201 |
+
async def test_simulate_risk_with_violations(self, client):
|
| 202 |
+
"""Test simulate risk with policy violations."""
|
| 203 |
+
# Add a monitor that returns violations
|
| 204 |
+
def violation_monitor(context):
|
| 205 |
+
return {
|
| 206 |
+
"type": "violation",
|
| 207 |
+
"severity": "high",
|
| 208 |
+
"violations": [{"rule": "admin_restriction", "message": "Unauthorized admin access", "severity": "high"}]
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
client.reflexive_engine.add_monitor(violation_monitor)
|
| 212 |
+
|
| 213 |
+
risk_scenario = {
|
| 214 |
+
"action_context": {
|
| 215 |
+
"action_id": "admin_action",
|
| 216 |
+
"actor_id": "guest_user",
|
| 217 |
+
"action_type": "admin_access",
|
| 218 |
+
"resource_id": "admin_panel"
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
response = await client.post("/core/simulate-risk", json=risk_scenario)
|
| 223 |
+
|
| 224 |
+
assert response.status_code == 200
|
| 225 |
+
data = response.json()
|
| 226 |
+
|
| 227 |
+
# Should result in a halt decision
|
| 228 |
+
decision = data["decision"]
|
| 229 |
+
assert decision["decision_type"] == "halt"
|
| 230 |
+
assert decision["risk_level"] == "high"
|
| 231 |
+
assert "violations" in decision["evidence"]
|
| 232 |
+
|
| 233 |
+
async def test_simulate_risk_with_anomalies(self, client):
|
| 234 |
+
"""Test simulate risk with anomalies."""
|
| 235 |
+
# Add a monitor that returns anomalies
|
| 236 |
+
def anomaly_monitor(context):
|
| 237 |
+
return {
|
| 238 |
+
"type": "anomaly",
|
| 239 |
+
"severity": "medium",
|
| 240 |
+
"anomalies": [{"type": "unusual_timing", "message": "Action at unusual hour", "severity": "medium"}]
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
client.reflexive_engine.add_monitor(anomaly_monitor)
|
| 244 |
+
|
| 245 |
+
risk_scenario = {
|
| 246 |
+
"action_context": {
|
| 247 |
+
"action_id": "unusual_action",
|
| 248 |
+
"actor_id": "test_user",
|
| 249 |
+
"action_type": "data_access",
|
| 250 |
+
"resource_id": "sensitive_data"
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
response = await client.post("/core/simulate-risk", json=risk_scenario)
|
| 255 |
+
|
| 256 |
+
assert response.status_code == 200
|
| 257 |
+
data = response.json()
|
| 258 |
+
|
| 259 |
+
# Should result in an escalate decision
|
| 260 |
+
decision = data["decision"]
|
| 261 |
+
assert decision["decision_type"] == "escalate"
|
| 262 |
+
assert decision["risk_level"] == "medium"
|
| 263 |
+
assert "anomalies" in decision["evidence"]
|
| 264 |
+
|
| 265 |
+
async def test_simulate_risk_no_issues(self, client):
|
| 266 |
+
"""Test simulate risk with no violations or anomalies."""
|
| 267 |
+
risk_scenario = {
|
| 268 |
+
"action_context": {
|
| 269 |
+
"action_id": "normal_action",
|
| 270 |
+
"actor_id": "authorized_user",
|
| 271 |
+
"action_type": "normal_operation",
|
| 272 |
+
"resource_id": "public_resource"
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
response = await client.post("/core/simulate-risk", json=risk_scenario)
|
| 277 |
+
|
| 278 |
+
assert response.status_code == 200
|
| 279 |
+
data = response.json()
|
| 280 |
+
|
| 281 |
+
# Should result in an allow decision
|
| 282 |
+
decision = data["decision"]
|
| 283 |
+
assert decision["decision_type"] == "allow"
|
| 284 |
+
assert decision["risk_level"] == "low"
|
| 285 |
+
assert decision["reason"] == "No violations or anomalies detected"
|
| 286 |
+
|
| 287 |
+
async def test_multiple_risk_scenarios(self, client):
|
| 288 |
+
"""Test multiple predefined risk scenarios."""
|
| 289 |
+
scenarios = [
|
| 290 |
+
{
|
| 291 |
+
"name": "admin_privilege_escalation",
|
| 292 |
+
"expected_decision": "halt",
|
| 293 |
+
"expected_risk": "high"
|
| 294 |
+
},
|
| 295 |
+
{
|
| 296 |
+
"name": "suspicious_activity",
|
| 297 |
+
"expected_decision": "escalate",
|
| 298 |
+
"expected_risk": "medium"
|
| 299 |
+
},
|
| 300 |
+
{
|
| 301 |
+
"name": "integrity_violation",
|
| 302 |
+
"expected_decision": "halt",
|
| 303 |
+
"expected_risk": "critical"
|
| 304 |
+
},
|
| 305 |
+
{
|
| 306 |
+
"name": "rate_limit_exceeded",
|
| 307 |
+
"expected_decision": "escalate",
|
| 308 |
+
"expected_risk": "medium"
|
| 309 |
+
}
|
| 310 |
+
]
|
| 311 |
+
|
| 312 |
+
for scenario in scenarios:
|
| 313 |
+
scenario_data = {
|
| 314 |
+
"scenario_name": scenario["name"],
|
| 315 |
+
"scenario_type": "test",
|
| 316 |
+
"parameters": {}
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
response = await client.post("/core/risk-scenario", json=scenario_data)
|
| 320 |
+
assert response.status_code == 200
|
| 321 |
+
|
| 322 |
+
data = response.json()
|
| 323 |
+
created_scenario = data["scenario"]
|
| 324 |
+
assert created_scenario["expected_decision"] == scenario["expected_decision"]
|
| 325 |
+
assert created_scenario["expected_risk_level"] == scenario["expected_risk"]
|