agent_os_kernel 3.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (337) hide show
  1. agent_control_plane/__init__.py +662 -0
  2. agent_control_plane/a2a_adapter.py +543 -0
  3. agent_control_plane/adapter.py +417 -0
  4. agent_control_plane/agent_hibernation.py +394 -0
  5. agent_control_plane/agent_kernel.py +470 -0
  6. agent_control_plane/compliance.py +720 -0
  7. agent_control_plane/constraint_graphs.py +478 -0
  8. agent_control_plane/control_plane.py +854 -0
  9. agent_control_plane/example_executors.py +195 -0
  10. agent_control_plane/execution_engine.py +231 -0
  11. agent_control_plane/flight_recorder.py +846 -0
  12. agent_control_plane/governance_layer.py +435 -0
  13. agent_control_plane/hf_utils.py +563 -0
  14. agent_control_plane/interfaces/__init__.py +55 -0
  15. agent_control_plane/interfaces/kernel_interface.py +361 -0
  16. agent_control_plane/interfaces/plugin_interface.py +497 -0
  17. agent_control_plane/interfaces/protocol_interfaces.py +387 -0
  18. agent_control_plane/kernel_space.py +1009 -0
  19. agent_control_plane/langchain_adapter.py +424 -0
  20. agent_control_plane/lifecycle.py +3113 -0
  21. agent_control_plane/mcp_adapter.py +653 -0
  22. agent_control_plane/ml_safety.py +563 -0
  23. agent_control_plane/multimodal.py +727 -0
  24. agent_control_plane/mute_agent.py +422 -0
  25. agent_control_plane/observability.py +787 -0
  26. agent_control_plane/orchestrator.py +482 -0
  27. agent_control_plane/plugin_registry.py +750 -0
  28. agent_control_plane/policy_engine.py +954 -0
  29. agent_control_plane/process_isolation.py +777 -0
  30. agent_control_plane/shadow_mode.py +310 -0
  31. agent_control_plane/signals.py +493 -0
  32. agent_control_plane/supervisor_agents.py +430 -0
  33. agent_control_plane/time_travel_debugger.py +557 -0
  34. agent_control_plane/tool_registry.py +452 -0
  35. agent_control_plane/vfs.py +697 -0
  36. agent_kernel/__init__.py +69 -0
  37. agent_kernel/analyzer.py +435 -0
  38. agent_kernel/auditor.py +36 -0
  39. agent_kernel/completeness_auditor.py +237 -0
  40. agent_kernel/detector.py +203 -0
  41. agent_kernel/kernel.py +744 -0
  42. agent_kernel/memory_manager.py +85 -0
  43. agent_kernel/models.py +374 -0
  44. agent_kernel/nudge_mechanism.py +263 -0
  45. agent_kernel/outcome_analyzer.py +338 -0
  46. agent_kernel/patcher.py +582 -0
  47. agent_kernel/semantic_analyzer.py +316 -0
  48. agent_kernel/semantic_purge.py +349 -0
  49. agent_kernel/simulator.py +449 -0
  50. agent_kernel/teacher.py +85 -0
  51. agent_kernel/triage.py +152 -0
  52. agent_os/__init__.py +409 -0
  53. agent_os/_adversarial_impl.py +200 -0
  54. agent_os/_circuit_breaker_impl.py +232 -0
  55. agent_os/_mcp_metrics.py +193 -0
  56. agent_os/adversarial.py +20 -0
  57. agent_os/agents_compat.py +490 -0
  58. agent_os/audit_logger.py +135 -0
  59. agent_os/base_agent.py +651 -0
  60. agent_os/circuit_breaker.py +34 -0
  61. agent_os/cli/__init__.py +659 -0
  62. agent_os/cli/cmd_audit.py +128 -0
  63. agent_os/cli/cmd_init.py +152 -0
  64. agent_os/cli/cmd_policy.py +41 -0
  65. agent_os/cli/cmd_policy_gen.py +180 -0
  66. agent_os/cli/cmd_validate.py +258 -0
  67. agent_os/cli/mcp_scan.py +265 -0
  68. agent_os/cli/output.py +192 -0
  69. agent_os/cli/policy_checker.py +330 -0
  70. agent_os/compat.py +74 -0
  71. agent_os/constraint_graph.py +234 -0
  72. agent_os/content_governance.py +140 -0
  73. agent_os/context_budget.py +305 -0
  74. agent_os/credential_redactor.py +224 -0
  75. agent_os/diff_policy.py +89 -0
  76. agent_os/egress_policy.py +159 -0
  77. agent_os/escalation.py +276 -0
  78. agent_os/event_bus.py +124 -0
  79. agent_os/exceptions.py +180 -0
  80. agent_os/execution_context_policy.py +141 -0
  81. agent_os/github_enterprise.py +96 -0
  82. agent_os/health.py +20 -0
  83. agent_os/integrations/__init__.py +279 -0
  84. agent_os/integrations/a2a_adapter.py +279 -0
  85. agent_os/integrations/agent_lightning/__init__.py +30 -0
  86. agent_os/integrations/anthropic_adapter.py +420 -0
  87. agent_os/integrations/autogen_adapter.py +620 -0
  88. agent_os/integrations/base.py +1137 -0
  89. agent_os/integrations/compat.py +229 -0
  90. agent_os/integrations/config.py +98 -0
  91. agent_os/integrations/conversation_guardian.py +957 -0
  92. agent_os/integrations/crewai_adapter.py +467 -0
  93. agent_os/integrations/drift_detector.py +425 -0
  94. agent_os/integrations/dry_run.py +124 -0
  95. agent_os/integrations/escalation.py +582 -0
  96. agent_os/integrations/gemini_adapter.py +364 -0
  97. agent_os/integrations/google_adk_adapter.py +633 -0
  98. agent_os/integrations/guardrails_adapter.py +394 -0
  99. agent_os/integrations/health.py +197 -0
  100. agent_os/integrations/langchain_adapter.py +654 -0
  101. agent_os/integrations/llamafirewall.py +343 -0
  102. agent_os/integrations/llamaindex_adapter.py +188 -0
  103. agent_os/integrations/logging.py +191 -0
  104. agent_os/integrations/maf_adapter.py +631 -0
  105. agent_os/integrations/mistral_adapter.py +365 -0
  106. agent_os/integrations/openai_adapter.py +816 -0
  107. agent_os/integrations/openai_agents_sdk.py +406 -0
  108. agent_os/integrations/policy_compose.py +171 -0
  109. agent_os/integrations/profiling.py +144 -0
  110. agent_os/integrations/pydantic_ai_adapter.py +420 -0
  111. agent_os/integrations/rate_limiter.py +130 -0
  112. agent_os/integrations/rbac.py +143 -0
  113. agent_os/integrations/registry.py +113 -0
  114. agent_os/integrations/scope_guard.py +303 -0
  115. agent_os/integrations/semantic_kernel_adapter.py +769 -0
  116. agent_os/integrations/smolagents_adapter.py +629 -0
  117. agent_os/integrations/templates.py +178 -0
  118. agent_os/integrations/token_budget.py +134 -0
  119. agent_os/integrations/tool_aliases.py +190 -0
  120. agent_os/integrations/webhooks.py +177 -0
  121. agent_os/lite.py +208 -0
  122. agent_os/mcp_gateway.py +385 -0
  123. agent_os/mcp_message_signer.py +273 -0
  124. agent_os/mcp_protocols.py +161 -0
  125. agent_os/mcp_response_scanner.py +232 -0
  126. agent_os/mcp_security.py +924 -0
  127. agent_os/mcp_session_auth.py +231 -0
  128. agent_os/mcp_sliding_rate_limiter.py +184 -0
  129. agent_os/memory_guard.py +409 -0
  130. agent_os/metrics.py +134 -0
  131. agent_os/mute.py +428 -0
  132. agent_os/mute_agent.py +209 -0
  133. agent_os/policies/__init__.py +77 -0
  134. agent_os/policies/async_evaluator.py +275 -0
  135. agent_os/policies/backends.py +670 -0
  136. agent_os/policies/bridge.py +169 -0
  137. agent_os/policies/budget.py +85 -0
  138. agent_os/policies/cli.py +294 -0
  139. agent_os/policies/conflict_resolution.py +270 -0
  140. agent_os/policies/data_classification.py +252 -0
  141. agent_os/policies/evaluator.py +239 -0
  142. agent_os/policies/policy_schema.json +228 -0
  143. agent_os/policies/rate_limiting.py +145 -0
  144. agent_os/policies/schema.py +115 -0
  145. agent_os/policies/shared.py +331 -0
  146. agent_os/prompt_injection.py +694 -0
  147. agent_os/providers.py +182 -0
  148. agent_os/py.typed +0 -0
  149. agent_os/retry.py +81 -0
  150. agent_os/reversibility.py +251 -0
  151. agent_os/sandbox.py +432 -0
  152. agent_os/sandbox_provider.py +140 -0
  153. agent_os/secure_codegen.py +525 -0
  154. agent_os/security_skills.py +538 -0
  155. agent_os/semantic_policy.py +422 -0
  156. agent_os/server/__init__.py +15 -0
  157. agent_os/server/__main__.py +25 -0
  158. agent_os/server/app.py +277 -0
  159. agent_os/server/models.py +104 -0
  160. agent_os/shift_left_metrics.py +130 -0
  161. agent_os/stateless.py +742 -0
  162. agent_os/supervisor.py +148 -0
  163. agent_os/task_outcome.py +148 -0
  164. agent_os/transparency.py +181 -0
  165. agent_os/trust_root.py +128 -0
  166. agent_os_kernel-3.1.0.dist-info/METADATA +1269 -0
  167. agent_os_kernel-3.1.0.dist-info/RECORD +337 -0
  168. agent_os_kernel-3.1.0.dist-info/WHEEL +4 -0
  169. agent_os_kernel-3.1.0.dist-info/entry_points.txt +2 -0
  170. agent_os_kernel-3.1.0.dist-info/licenses/LICENSE +21 -0
  171. agent_os_observability/__init__.py +27 -0
  172. agent_os_observability/dashboards.py +898 -0
  173. agent_os_observability/metrics.py +398 -0
  174. agent_os_observability/server.py +223 -0
  175. agent_os_observability/tracer.py +232 -0
  176. agent_primitives/__init__.py +24 -0
  177. agent_primitives/failures.py +84 -0
  178. agent_primitives/py.typed +0 -0
  179. amb_core/__init__.py +177 -0
  180. amb_core/adapters/__init__.py +57 -0
  181. amb_core/adapters/aws_sqs_broker.py +376 -0
  182. amb_core/adapters/azure_servicebus_broker.py +340 -0
  183. amb_core/adapters/kafka_broker.py +260 -0
  184. amb_core/adapters/nats_broker.py +285 -0
  185. amb_core/adapters/rabbitmq_broker.py +235 -0
  186. amb_core/adapters/redis_broker.py +262 -0
  187. amb_core/broker.py +145 -0
  188. amb_core/bus.py +481 -0
  189. amb_core/cloudevents.py +509 -0
  190. amb_core/dlq.py +345 -0
  191. amb_core/hf_utils.py +536 -0
  192. amb_core/memory_broker.py +410 -0
  193. amb_core/models.py +141 -0
  194. amb_core/persistence.py +529 -0
  195. amb_core/schema.py +294 -0
  196. amb_core/tracing.py +358 -0
  197. atr/__init__.py +640 -0
  198. atr/access.py +348 -0
  199. atr/composition.py +645 -0
  200. atr/decorator.py +357 -0
  201. atr/executor.py +384 -0
  202. atr/health.py +557 -0
  203. atr/hf_utils.py +449 -0
  204. atr/injection.py +422 -0
  205. atr/metrics.py +440 -0
  206. atr/policies.py +403 -0
  207. atr/py.typed +2 -0
  208. atr/registry.py +452 -0
  209. atr/schema.py +480 -0
  210. atr/tools/safe/__init__.py +75 -0
  211. atr/tools/safe/calculator.py +467 -0
  212. atr/tools/safe/datetime_tool.py +443 -0
  213. atr/tools/safe/file_reader.py +402 -0
  214. atr/tools/safe/http_client.py +316 -0
  215. atr/tools/safe/json_parser.py +374 -0
  216. atr/tools/safe/text_tool.py +537 -0
  217. atr/tools/safe/toolkit.py +175 -0
  218. caas/__init__.py +162 -0
  219. caas/api/__init__.py +7 -0
  220. caas/api/server.py +1328 -0
  221. caas/caching.py +834 -0
  222. caas/cli.py +210 -0
  223. caas/conversation.py +223 -0
  224. caas/decay.py +72 -0
  225. caas/detection/__init__.py +9 -0
  226. caas/detection/detector.py +238 -0
  227. caas/enrichment.py +130 -0
  228. caas/gateway/__init__.py +27 -0
  229. caas/gateway/trust_gateway.py +474 -0
  230. caas/hf_utils.py +479 -0
  231. caas/ingestion/__init__.py +23 -0
  232. caas/ingestion/processors.py +253 -0
  233. caas/ingestion/structure_parser.py +188 -0
  234. caas/models.py +356 -0
  235. caas/pragmatic_truth.py +444 -0
  236. caas/routing/__init__.py +10 -0
  237. caas/routing/heuristic_router.py +58 -0
  238. caas/storage/__init__.py +9 -0
  239. caas/storage/store.py +389 -0
  240. caas/triad.py +213 -0
  241. caas/tuning/__init__.py +9 -0
  242. caas/tuning/tuner.py +329 -0
  243. caas/vfs/__init__.py +14 -0
  244. caas/vfs/filesystem.py +452 -0
  245. cmvk/__init__.py +218 -0
  246. cmvk/audit.py +402 -0
  247. cmvk/benchmarks.py +478 -0
  248. cmvk/constitutional.py +904 -0
  249. cmvk/hf_utils.py +301 -0
  250. cmvk/metrics.py +473 -0
  251. cmvk/profiles.py +300 -0
  252. cmvk/py.typed +0 -0
  253. cmvk/types.py +12 -0
  254. cmvk/verification.py +956 -0
  255. emk/__init__.py +89 -0
  256. emk/causal.py +352 -0
  257. emk/hf_utils.py +421 -0
  258. emk/indexer.py +83 -0
  259. emk/py.typed +0 -0
  260. emk/schema.py +204 -0
  261. emk/sleep_cycle.py +347 -0
  262. emk/store.py +281 -0
  263. iatp/__init__.py +166 -0
  264. iatp/attestation.py +461 -0
  265. iatp/cli.py +317 -0
  266. iatp/hf_utils.py +472 -0
  267. iatp/ipc_pipes.py +580 -0
  268. iatp/main.py +412 -0
  269. iatp/models/__init__.py +447 -0
  270. iatp/policy_engine.py +337 -0
  271. iatp/py.typed +2 -0
  272. iatp/recovery.py +321 -0
  273. iatp/security/__init__.py +270 -0
  274. iatp/sidecar/__init__.py +519 -0
  275. iatp/telemetry/__init__.py +164 -0
  276. iatp/tests/__init__.py +1 -0
  277. iatp/tests/test_attestation.py +370 -0
  278. iatp/tests/test_cli.py +131 -0
  279. iatp/tests/test_ed25519_attestation.py +211 -0
  280. iatp/tests/test_models.py +130 -0
  281. iatp/tests/test_policy_engine.py +347 -0
  282. iatp/tests/test_recovery.py +281 -0
  283. iatp/tests/test_security.py +222 -0
  284. iatp/tests/test_sidecar.py +167 -0
  285. iatp/tests/test_telemetry.py +175 -0
  286. mcp_kernel_server/__init__.py +28 -0
  287. mcp_kernel_server/cli.py +274 -0
  288. mcp_kernel_server/resources.py +217 -0
  289. mcp_kernel_server/server.py +564 -0
  290. mcp_kernel_server/tools.py +1174 -0
  291. mute_agent/__init__.py +68 -0
  292. mute_agent/core/__init__.py +1 -0
  293. mute_agent/core/execution_agent.py +166 -0
  294. mute_agent/core/handshake_protocol.py +201 -0
  295. mute_agent/core/reasoning_agent.py +238 -0
  296. mute_agent/knowledge_graph/__init__.py +1 -0
  297. mute_agent/knowledge_graph/graph_elements.py +65 -0
  298. mute_agent/knowledge_graph/multidimensional_graph.py +170 -0
  299. mute_agent/knowledge_graph/subgraph.py +224 -0
  300. mute_agent/listener/__init__.py +43 -0
  301. mute_agent/listener/adapters/__init__.py +31 -0
  302. mute_agent/listener/adapters/base_adapter.py +189 -0
  303. mute_agent/listener/adapters/caas_adapter.py +344 -0
  304. mute_agent/listener/adapters/control_plane_adapter.py +436 -0
  305. mute_agent/listener/adapters/iatp_adapter.py +332 -0
  306. mute_agent/listener/adapters/scak_adapter.py +251 -0
  307. mute_agent/listener/listener.py +610 -0
  308. mute_agent/listener/state_observer.py +436 -0
  309. mute_agent/listener/threshold_config.py +313 -0
  310. mute_agent/super_system/__init__.py +1 -0
  311. mute_agent/super_system/router.py +204 -0
  312. mute_agent/visualization/__init__.py +10 -0
  313. mute_agent/visualization/graph_debugger.py +502 -0
  314. nexus/README.md +60 -0
  315. nexus/__init__.py +51 -0
  316. nexus/arbiter.py +359 -0
  317. nexus/client.py +466 -0
  318. nexus/dmz.py +444 -0
  319. nexus/escrow.py +430 -0
  320. nexus/exceptions.py +286 -0
  321. nexus/pyproject.toml +36 -0
  322. nexus/registry.py +393 -0
  323. nexus/reputation.py +425 -0
  324. nexus/schemas/__init__.py +51 -0
  325. nexus/schemas/compliance.py +276 -0
  326. nexus/schemas/escrow.py +251 -0
  327. nexus/schemas/manifest.py +225 -0
  328. nexus/schemas/receipt.py +208 -0
  329. nexus/tests/__init__.py +0 -0
  330. nexus/tests/conftest.py +146 -0
  331. nexus/tests/test_arbiter.py +192 -0
  332. nexus/tests/test_dmz.py +194 -0
  333. nexus/tests/test_escrow.py +276 -0
  334. nexus/tests/test_exceptions.py +225 -0
  335. nexus/tests/test_registry.py +232 -0
  336. nexus/tests/test_reputation.py +328 -0
  337. nexus/tests/test_schemas.py +295 -0
@@ -0,0 +1,582 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+ """
4
+ Human-in-the-Loop Escalation for Governance Policies.
5
+
6
+ Adds an ``ESCALATE`` decision tier between ALLOW and DENY. When a policy
7
+ requires human approval, the agent is **suspended** and an approval request
8
+ is routed to a configurable backend (in-memory queue, webhook, or custom
9
+ handler). A timeout with configurable default action ensures the system
10
+ never blocks indefinitely.
11
+
12
+ Usage:
13
+ from agent_os.integrations.escalation import (
14
+ EscalationHandler,
15
+ EscalationPolicy,
16
+ EscalationRequest,
17
+ EscalationDecision,
18
+ InMemoryApprovalQueue,
19
+ )
20
+
21
+ queue = InMemoryApprovalQueue()
22
+ handler = EscalationHandler(backend=queue, timeout_seconds=300)
23
+ policy = EscalationPolicy(integration, handler=handler)
24
+
25
+ result = policy.evaluate("tool_call", context, input_data)
26
+ if result.decision == EscalationDecision.PENDING:
27
+ # Agent is suspended — await human decision
28
+ queue.approve(result.request_id, approver="admin@corp.com")
29
+ final = policy.resolve(result.request_id)
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import abc
35
+ import logging
36
+ import threading
37
+ import uuid
38
+ from dataclasses import dataclass, field
39
+ from datetime import datetime, timedelta, timezone
40
+ from enum import Enum
41
+ from typing import Any, Callable, Optional
42
+
43
+ from .base import BaseIntegration, ExecutionContext, GovernanceEventType
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ class EscalationDecision(Enum):
49
+ """Possible outcomes of an escalation evaluation."""
50
+
51
+ ALLOW = "ALLOW"
52
+ DENY = "DENY"
53
+ ESCALATE = "ESCALATE"
54
+ PENDING = "PENDING"
55
+ TIMEOUT = "TIMEOUT"
56
+
57
+
58
+ class DefaultTimeoutAction(Enum):
59
+ """Action to take when a human doesn't respond within the SLA."""
60
+
61
+ DENY = "deny"
62
+ ALLOW = "allow"
63
+
64
+
65
+ @dataclass
66
+ class QuorumConfig:
67
+ """Configuration for M-of-N approval quorum.
68
+
69
+ When set, an escalation requires at least ``required_approvals``
70
+ ALLOW votes from distinct approvers before the action is permitted.
71
+ A single DENY from any approver is enough to deny immediately
72
+ unless ``required_denials`` is set.
73
+
74
+ Attributes:
75
+ required_approvals: Minimum ALLOW votes needed (M).
76
+ total_approvers: Total approver pool size (N). Informational.
77
+ required_denials: Number of DENY votes to reject (default 1).
78
+ """
79
+
80
+ required_approvals: int = 2
81
+ total_approvers: int = 3
82
+ required_denials: int = 1
83
+
84
+ def __post_init__(self) -> None:
85
+ if self.required_approvals < 1:
86
+ raise ValueError("required_approvals must be >= 1")
87
+ if self.required_denials < 1:
88
+ raise ValueError("required_denials must be >= 1")
89
+
90
+
91
+ @dataclass
92
+ class EscalationRequest:
93
+ """A request for human approval of an agent action.
94
+
95
+ Attributes:
96
+ request_id: Unique identifier for this escalation.
97
+ agent_id: ID of the agent whose action needs approval.
98
+ action: Description of the action being escalated.
99
+ reason: Why escalation was triggered.
100
+ context_snapshot: Serialisable snapshot of the execution context.
101
+ created_at: When the escalation was created.
102
+ resolved_at: When a human responded (or timeout).
103
+ decision: Final decision from the human (or timeout default).
104
+ resolved_by: Identifier of the human who resolved.
105
+ """
106
+
107
+ request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
108
+ agent_id: str = ""
109
+ action: str = ""
110
+ reason: str = ""
111
+ context_snapshot: dict[str, Any] = field(default_factory=dict)
112
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
113
+ resolved_at: Optional[datetime] = None
114
+ decision: EscalationDecision = EscalationDecision.PENDING
115
+ resolved_by: Optional[str] = None
116
+ # Quorum tracking: list of (approver, decision, timestamp) votes
117
+ votes: list[tuple[str, str, datetime]] = field(default_factory=list)
118
+
119
+
120
+ class ApprovalBackend(abc.ABC):
121
+ """Abstract interface for escalation approval backends."""
122
+
123
+ @abc.abstractmethod
124
+ def submit(self, request: EscalationRequest) -> None:
125
+ """Submit an escalation request for human review."""
126
+
127
+ @abc.abstractmethod
128
+ def get_decision(self, request_id: str) -> EscalationRequest | None:
129
+ """Retrieve the current state of an escalation request."""
130
+
131
+ @abc.abstractmethod
132
+ def approve(self, request_id: str, approver: str = "") -> bool:
133
+ """Approve an escalation request. Returns True if found and updated."""
134
+
135
+ @abc.abstractmethod
136
+ def deny(self, request_id: str, approver: str = "") -> bool:
137
+ """Deny an escalation request. Returns True if found and updated."""
138
+
139
+ @abc.abstractmethod
140
+ def list_pending(self) -> list[EscalationRequest]:
141
+ """List all pending escalation requests."""
142
+
143
+
144
+ class InMemoryApprovalQueue(ApprovalBackend):
145
+ """Thread-safe in-memory approval queue.
146
+
147
+ Suitable for testing, single-process deployments, and development.
148
+ For production, implement a backend that uses Redis, a database,
149
+ or a webhook-based notification service.
150
+ """
151
+
152
+ def __init__(self) -> None:
153
+ self._requests: dict[str, EscalationRequest] = {}
154
+ self._lock = threading.Lock()
155
+ self._events: dict[str, threading.Event] = {}
156
+
157
+ def submit(self, request: EscalationRequest) -> None:
158
+ with self._lock:
159
+ self._requests[request.request_id] = request
160
+ self._events[request.request_id] = threading.Event()
161
+
162
+ def get_decision(self, request_id: str) -> EscalationRequest | None:
163
+ with self._lock:
164
+ return self._requests.get(request_id)
165
+
166
+ def approve(self, request_id: str, approver: str = "") -> bool:
167
+ with self._lock:
168
+ req = self._requests.get(request_id)
169
+ if req is None or req.decision != EscalationDecision.PENDING:
170
+ return False
171
+ req.decision = EscalationDecision.ALLOW
172
+ req.resolved_by = approver
173
+ req.resolved_at = datetime.now(timezone.utc)
174
+ event = self._events.get(request_id)
175
+ if event:
176
+ event.set()
177
+ return True
178
+
179
+ def deny(self, request_id: str, approver: str = "") -> bool:
180
+ with self._lock:
181
+ req = self._requests.get(request_id)
182
+ if req is None or req.decision != EscalationDecision.PENDING:
183
+ return False
184
+ req.decision = EscalationDecision.DENY
185
+ req.resolved_by = approver
186
+ req.resolved_at = datetime.now(timezone.utc)
187
+ event = self._events.get(request_id)
188
+ if event:
189
+ event.set()
190
+ return True
191
+
192
+ def list_pending(self) -> list[EscalationRequest]:
193
+ with self._lock:
194
+ return [
195
+ r
196
+ for r in self._requests.values()
197
+ if r.decision == EscalationDecision.PENDING
198
+ ]
199
+
200
+ def wait_for_decision(
201
+ self, request_id: str, timeout: float | None = None
202
+ ) -> EscalationDecision:
203
+ """Block until a decision is made or timeout expires.
204
+
205
+ Returns:
206
+ The final decision, or ``PENDING`` if timeout was reached.
207
+ """
208
+ event = self._events.get(request_id)
209
+ if event is None:
210
+ return EscalationDecision.PENDING
211
+ event.wait(timeout=timeout)
212
+ req = self._requests.get(request_id)
213
+ return req.decision if req else EscalationDecision.PENDING
214
+
215
+
216
+ class WebhookApprovalBackend(ApprovalBackend):
217
+ """Approval backend that sends webhook notifications for escalations.
218
+
219
+ Stores state in-memory but fires an HTTP POST to the configured URL
220
+ when a new escalation is submitted. The receiving system is responsible
221
+ for calling back via the ``approve``/``deny`` methods (e.g., via an
222
+ API endpoint).
223
+
224
+ Args:
225
+ webhook_url: URL to POST escalation notifications to.
226
+ headers: Optional HTTP headers (e.g., auth tokens).
227
+ """
228
+
229
+ def __init__(
230
+ self,
231
+ webhook_url: str,
232
+ headers: dict[str, str] | None = None,
233
+ ) -> None:
234
+ self._inner = InMemoryApprovalQueue()
235
+ self._webhook_url = webhook_url
236
+ self._headers = headers or {}
237
+
238
+ def submit(self, request: EscalationRequest) -> None:
239
+ self._inner.submit(request)
240
+ self._notify(request)
241
+
242
+ def _notify(self, request: EscalationRequest) -> None:
243
+ """Fire-and-forget webhook notification."""
244
+ try:
245
+ import urllib.request
246
+ import json
247
+
248
+ payload = json.dumps(
249
+ {
250
+ "request_id": request.request_id,
251
+ "agent_id": request.agent_id,
252
+ "action": request.action,
253
+ "reason": request.reason,
254
+ "created_at": request.created_at.isoformat(),
255
+ },
256
+ default=str,
257
+ ).encode()
258
+ req = urllib.request.Request(
259
+ self._webhook_url,
260
+ data=payload,
261
+ headers={**self._headers, "Content-Type": "application/json"},
262
+ method="POST",
263
+ )
264
+ urllib.request.urlopen(req, timeout=10) # noqa: S310
265
+ logger.info("Escalation webhook sent for %s", request.request_id)
266
+ except Exception:
267
+ logger.warning(
268
+ "Failed to send escalation webhook for %s",
269
+ request.request_id,
270
+ exc_info=True,
271
+ )
272
+
273
+ def get_decision(self, request_id: str) -> EscalationRequest | None:
274
+ return self._inner.get_decision(request_id)
275
+
276
+ def approve(self, request_id: str, approver: str = "") -> bool:
277
+ return self._inner.approve(request_id, approver)
278
+
279
+ def deny(self, request_id: str, approver: str = "") -> bool:
280
+ return self._inner.deny(request_id, approver)
281
+
282
+ def list_pending(self) -> list[EscalationRequest]:
283
+ return self._inner.list_pending()
284
+
285
+
286
+ class EscalationHandler:
287
+ """Manages escalation lifecycle: submit, wait, resolve.
288
+
289
+ Args:
290
+ backend: The approval backend to use.
291
+ timeout_seconds: How long to wait for a human decision.
292
+ default_action: What to do if the timeout expires.
293
+ on_escalate: Optional callback fired when an escalation is created.
294
+ quorum: Optional quorum configuration for M-of-N approval.
295
+ When set, approvals/denials are counted against quorum
296
+ thresholds before a final decision is reached.
297
+ fatigue_window_seconds: Rolling window (in seconds) for fatigue
298
+ detection. Defaults to 60 (one minute).
299
+ fatigue_threshold: Maximum number of escalations per agent within
300
+ the fatigue window. If exceeded, new escalations are
301
+ auto-denied. ``None`` disables fatigue detection.
302
+ """
303
+
304
+ def __init__(
305
+ self,
306
+ backend: ApprovalBackend | None = None,
307
+ timeout_seconds: float = 300,
308
+ default_action: DefaultTimeoutAction = DefaultTimeoutAction.DENY,
309
+ on_escalate: Callable[[EscalationRequest], None] | None = None,
310
+ quorum: QuorumConfig | None = None,
311
+ fatigue_window_seconds: float = 60.0,
312
+ fatigue_threshold: int | None = None,
313
+ ) -> None:
314
+ self.backend = backend or InMemoryApprovalQueue()
315
+ self.timeout_seconds = timeout_seconds
316
+ self.default_action = default_action
317
+ self._on_escalate = on_escalate
318
+ self.quorum = quorum
319
+ self._fatigue_window = fatigue_window_seconds
320
+ self._fatigue_threshold = fatigue_threshold
321
+ # Per-agent escalation timestamps for fatigue detection
322
+ self._escalation_times: dict[str, list[datetime]] = {}
323
+
324
+ def _check_fatigue(self, agent_id: str) -> bool:
325
+ """Return True if the agent is triggering escalations too rapidly.
326
+
327
+ When fatigue detection is enabled, auto-DENY prevents an agent
328
+ from overwhelming human reviewers with a flood of requests (the
329
+ approval-fatigue attack described in Ona/Veto research).
330
+ """
331
+ if self._fatigue_threshold is None:
332
+ return False
333
+
334
+ now = datetime.now(timezone.utc)
335
+ cutoff = now - timedelta(seconds=self._fatigue_window)
336
+ times = self._escalation_times.get(agent_id, [])
337
+ # Prune old timestamps
338
+ recent = [t for t in times if t > cutoff]
339
+ self._escalation_times[agent_id] = recent
340
+ return len(recent) >= self._fatigue_threshold
341
+
342
+ def escalate(
343
+ self,
344
+ agent_id: str,
345
+ action: str,
346
+ reason: str,
347
+ context_snapshot: dict[str, Any] | None = None,
348
+ ) -> EscalationRequest:
349
+ """Create and submit an escalation request.
350
+
351
+ If fatigue detection is enabled and the agent has exceeded the
352
+ threshold, the request is immediately auto-denied.
353
+
354
+ Returns:
355
+ The ``EscalationRequest`` — PENDING normally, DENY if fatigued.
356
+ """
357
+ # Fatigue check
358
+ if self._check_fatigue(agent_id):
359
+ logger.warning(
360
+ "Escalation fatigue: agent %s exceeded %d escalations in %.0fs — auto-DENY",
361
+ agent_id,
362
+ self._fatigue_threshold,
363
+ self._fatigue_window,
364
+ )
365
+ request = EscalationRequest(
366
+ agent_id=agent_id,
367
+ action=action,
368
+ reason=f"Auto-denied: escalation fatigue ({reason})",
369
+ context_snapshot=context_snapshot or {},
370
+ decision=EscalationDecision.DENY,
371
+ resolved_at=datetime.now(timezone.utc),
372
+ resolved_by="system:fatigue_detector",
373
+ )
374
+ return request
375
+
376
+ # Record timestamp for fatigue tracking
377
+ self._escalation_times.setdefault(agent_id, []).append(
378
+ datetime.now(timezone.utc)
379
+ )
380
+
381
+ request = EscalationRequest(
382
+ agent_id=agent_id,
383
+ action=action,
384
+ reason=reason,
385
+ context_snapshot=context_snapshot or {},
386
+ )
387
+ self.backend.submit(request)
388
+ logger.info(
389
+ "Escalation %s created for agent %s: %s",
390
+ request.request_id,
391
+ agent_id,
392
+ reason,
393
+ )
394
+ if self._on_escalate:
395
+ self._on_escalate(request)
396
+ return request
397
+
398
+ def resolve(self, request_id: str) -> EscalationDecision:
399
+ """Check or wait for a resolution.
400
+
401
+ For ``InMemoryApprovalQueue``, this blocks up to ``timeout_seconds``.
402
+ For other backends, this polls once and returns the current state.
403
+
404
+ When quorum is configured, the decision is evaluated against
405
+ quorum thresholds instead of accepting a single vote.
406
+
407
+ Returns:
408
+ The final decision. If the timeout expires, applies the
409
+ ``default_action`` and returns that.
410
+ """
411
+ if isinstance(self.backend, InMemoryApprovalQueue):
412
+ decision = self.backend.wait_for_decision(
413
+ request_id, timeout=self.timeout_seconds
414
+ )
415
+ else:
416
+ req = self.backend.get_decision(request_id)
417
+ decision = req.decision if req else EscalationDecision.PENDING
418
+
419
+ # Quorum evaluation
420
+ if self.quorum and decision != EscalationDecision.PENDING:
421
+ req = self.backend.get_decision(request_id)
422
+ if req:
423
+ approvals = sum(1 for _, v, _ in req.votes if v == "ALLOW")
424
+ denials = sum(1 for _, v, _ in req.votes if v == "DENY")
425
+
426
+ if denials >= self.quorum.required_denials:
427
+ return EscalationDecision.DENY
428
+ if approvals >= self.quorum.required_approvals:
429
+ return EscalationDecision.ALLOW
430
+ # Not enough votes yet — treat as pending/timeout
431
+ decision = EscalationDecision.PENDING
432
+
433
+ if decision == EscalationDecision.PENDING:
434
+ # Timeout — apply default
435
+ decision = (
436
+ EscalationDecision.ALLOW
437
+ if self.default_action == DefaultTimeoutAction.ALLOW
438
+ else EscalationDecision.DENY
439
+ )
440
+ logger.warning(
441
+ "Escalation %s timed out after %.0fs, defaulting to %s",
442
+ request_id,
443
+ self.timeout_seconds,
444
+ decision.value,
445
+ )
446
+ return decision
447
+
448
+
449
+ @dataclass
450
+ class EscalationResult:
451
+ """Result of an escalation policy evaluation."""
452
+
453
+ action: str
454
+ decision: EscalationDecision
455
+ reason: Optional[str]
456
+ request: Optional[EscalationRequest] = None
457
+ policy_name: str = ""
458
+ timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
459
+
460
+
461
+ class EscalationPolicy:
462
+ """Wraps a BaseIntegration with human-in-the-loop escalation.
463
+
464
+ When ``require_human_approval`` is True in the policy, instead of
465
+ immediately denying the action, this wrapper **suspends** execution
466
+ and routes an approval request to the configured handler.
467
+
468
+ This is the ``ESCALATE`` tier between ALLOW and DENY.
469
+
470
+ Args:
471
+ integration: The governance integration to wrap.
472
+ handler: The escalation handler managing approvals.
473
+ policy_name: Name for audit logging.
474
+ """
475
+
476
+ def __init__(
477
+ self,
478
+ integration: BaseIntegration,
479
+ handler: EscalationHandler | None = None,
480
+ *,
481
+ policy_name: str = "default",
482
+ ) -> None:
483
+ self._integration = integration
484
+ self._handler = handler or EscalationHandler()
485
+ self._policy_name = policy_name
486
+
487
+ @property
488
+ def handler(self) -> EscalationHandler:
489
+ return self._handler
490
+
491
+ def evaluate(
492
+ self,
493
+ action: str,
494
+ context: ExecutionContext,
495
+ input_data: Any = None,
496
+ ) -> EscalationResult:
497
+ """Evaluate a policy check with escalation support.
498
+
499
+ If the policy would deny due to ``require_human_approval``,
500
+ this creates an escalation request instead of blocking.
501
+
502
+ For all other deny reasons (blocked patterns, timeouts, etc.),
503
+ the action is denied immediately — escalation only applies
504
+ to the human-approval gate.
505
+
506
+ Returns:
507
+ An ``EscalationResult`` with the decision and optional
508
+ escalation request.
509
+ """
510
+ allowed, reason = self._integration.pre_execute(context, input_data)
511
+
512
+ if allowed:
513
+ return EscalationResult(
514
+ action=action,
515
+ decision=EscalationDecision.ALLOW,
516
+ reason=None,
517
+ policy_name=self._policy_name,
518
+ )
519
+
520
+ # Check if this denial was due to human approval requirement
521
+ if self._integration.policy.require_human_approval and reason and (
522
+ "human approval" in reason.lower()
523
+ ):
524
+ request = self._handler.escalate(
525
+ agent_id=context.agent_id,
526
+ action=action,
527
+ reason=reason,
528
+ context_snapshot={
529
+ "session_id": context.session_id,
530
+ "call_count": context.call_count,
531
+ "total_tokens": context.total_tokens,
532
+ "input_summary": str(input_data)[:500] if input_data else "",
533
+ },
534
+ )
535
+ self._integration.emit(
536
+ GovernanceEventType.POLICY_CHECK,
537
+ {
538
+ "agent_id": context.agent_id,
539
+ "action": action,
540
+ "escalation_id": request.request_id,
541
+ "phase": "escalated",
542
+ },
543
+ )
544
+ return EscalationResult(
545
+ action=action,
546
+ decision=EscalationDecision.PENDING,
547
+ reason=reason,
548
+ request=request,
549
+ policy_name=self._policy_name,
550
+ )
551
+
552
+ # Hard deny (not an escalation scenario)
553
+ return EscalationResult(
554
+ action=action,
555
+ decision=EscalationDecision.DENY,
556
+ reason=reason,
557
+ policy_name=self._policy_name,
558
+ )
559
+
560
+ def resolve(self, request_id: str) -> EscalationDecision:
561
+ """Wait for and return the human decision on an escalation.
562
+
563
+ Delegates to the handler's resolve method, which blocks or
564
+ polls depending on the backend.
565
+ """
566
+ return self._handler.resolve(request_id)
567
+
568
+ def evaluate_and_wait(
569
+ self,
570
+ action: str,
571
+ context: ExecutionContext,
572
+ input_data: Any = None,
573
+ ) -> EscalationResult:
574
+ """Evaluate and, if escalated, block until resolved.
575
+
576
+ Convenience method that combines ``evaluate()`` and ``resolve()``.
577
+ """
578
+ result = self.evaluate(action, context, input_data)
579
+ if result.decision == EscalationDecision.PENDING and result.request:
580
+ final = self.resolve(result.request.request_id)
581
+ result.decision = final
582
+ return result