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.
- agent_control_plane/__init__.py +662 -0
- agent_control_plane/a2a_adapter.py +543 -0
- agent_control_plane/adapter.py +417 -0
- agent_control_plane/agent_hibernation.py +394 -0
- agent_control_plane/agent_kernel.py +470 -0
- agent_control_plane/compliance.py +720 -0
- agent_control_plane/constraint_graphs.py +478 -0
- agent_control_plane/control_plane.py +854 -0
- agent_control_plane/example_executors.py +195 -0
- agent_control_plane/execution_engine.py +231 -0
- agent_control_plane/flight_recorder.py +846 -0
- agent_control_plane/governance_layer.py +435 -0
- agent_control_plane/hf_utils.py +563 -0
- agent_control_plane/interfaces/__init__.py +55 -0
- agent_control_plane/interfaces/kernel_interface.py +361 -0
- agent_control_plane/interfaces/plugin_interface.py +497 -0
- agent_control_plane/interfaces/protocol_interfaces.py +387 -0
- agent_control_plane/kernel_space.py +1009 -0
- agent_control_plane/langchain_adapter.py +424 -0
- agent_control_plane/lifecycle.py +3113 -0
- agent_control_plane/mcp_adapter.py +653 -0
- agent_control_plane/ml_safety.py +563 -0
- agent_control_plane/multimodal.py +727 -0
- agent_control_plane/mute_agent.py +422 -0
- agent_control_plane/observability.py +787 -0
- agent_control_plane/orchestrator.py +482 -0
- agent_control_plane/plugin_registry.py +750 -0
- agent_control_plane/policy_engine.py +954 -0
- agent_control_plane/process_isolation.py +777 -0
- agent_control_plane/shadow_mode.py +310 -0
- agent_control_plane/signals.py +493 -0
- agent_control_plane/supervisor_agents.py +430 -0
- agent_control_plane/time_travel_debugger.py +557 -0
- agent_control_plane/tool_registry.py +452 -0
- agent_control_plane/vfs.py +697 -0
- agent_kernel/__init__.py +69 -0
- agent_kernel/analyzer.py +435 -0
- agent_kernel/auditor.py +36 -0
- agent_kernel/completeness_auditor.py +237 -0
- agent_kernel/detector.py +203 -0
- agent_kernel/kernel.py +744 -0
- agent_kernel/memory_manager.py +85 -0
- agent_kernel/models.py +374 -0
- agent_kernel/nudge_mechanism.py +263 -0
- agent_kernel/outcome_analyzer.py +338 -0
- agent_kernel/patcher.py +582 -0
- agent_kernel/semantic_analyzer.py +316 -0
- agent_kernel/semantic_purge.py +349 -0
- agent_kernel/simulator.py +449 -0
- agent_kernel/teacher.py +85 -0
- agent_kernel/triage.py +152 -0
- agent_os/__init__.py +409 -0
- agent_os/_adversarial_impl.py +200 -0
- agent_os/_circuit_breaker_impl.py +232 -0
- agent_os/_mcp_metrics.py +193 -0
- agent_os/adversarial.py +20 -0
- agent_os/agents_compat.py +490 -0
- agent_os/audit_logger.py +135 -0
- agent_os/base_agent.py +651 -0
- agent_os/circuit_breaker.py +34 -0
- agent_os/cli/__init__.py +659 -0
- agent_os/cli/cmd_audit.py +128 -0
- agent_os/cli/cmd_init.py +152 -0
- agent_os/cli/cmd_policy.py +41 -0
- agent_os/cli/cmd_policy_gen.py +180 -0
- agent_os/cli/cmd_validate.py +258 -0
- agent_os/cli/mcp_scan.py +265 -0
- agent_os/cli/output.py +192 -0
- agent_os/cli/policy_checker.py +330 -0
- agent_os/compat.py +74 -0
- agent_os/constraint_graph.py +234 -0
- agent_os/content_governance.py +140 -0
- agent_os/context_budget.py +305 -0
- agent_os/credential_redactor.py +224 -0
- agent_os/diff_policy.py +89 -0
- agent_os/egress_policy.py +159 -0
- agent_os/escalation.py +276 -0
- agent_os/event_bus.py +124 -0
- agent_os/exceptions.py +180 -0
- agent_os/execution_context_policy.py +141 -0
- agent_os/github_enterprise.py +96 -0
- agent_os/health.py +20 -0
- agent_os/integrations/__init__.py +279 -0
- agent_os/integrations/a2a_adapter.py +279 -0
- agent_os/integrations/agent_lightning/__init__.py +30 -0
- agent_os/integrations/anthropic_adapter.py +420 -0
- agent_os/integrations/autogen_adapter.py +620 -0
- agent_os/integrations/base.py +1137 -0
- agent_os/integrations/compat.py +229 -0
- agent_os/integrations/config.py +98 -0
- agent_os/integrations/conversation_guardian.py +957 -0
- agent_os/integrations/crewai_adapter.py +467 -0
- agent_os/integrations/drift_detector.py +425 -0
- agent_os/integrations/dry_run.py +124 -0
- agent_os/integrations/escalation.py +582 -0
- agent_os/integrations/gemini_adapter.py +364 -0
- agent_os/integrations/google_adk_adapter.py +633 -0
- agent_os/integrations/guardrails_adapter.py +394 -0
- agent_os/integrations/health.py +197 -0
- agent_os/integrations/langchain_adapter.py +654 -0
- agent_os/integrations/llamafirewall.py +343 -0
- agent_os/integrations/llamaindex_adapter.py +188 -0
- agent_os/integrations/logging.py +191 -0
- agent_os/integrations/maf_adapter.py +631 -0
- agent_os/integrations/mistral_adapter.py +365 -0
- agent_os/integrations/openai_adapter.py +816 -0
- agent_os/integrations/openai_agents_sdk.py +406 -0
- agent_os/integrations/policy_compose.py +171 -0
- agent_os/integrations/profiling.py +144 -0
- agent_os/integrations/pydantic_ai_adapter.py +420 -0
- agent_os/integrations/rate_limiter.py +130 -0
- agent_os/integrations/rbac.py +143 -0
- agent_os/integrations/registry.py +113 -0
- agent_os/integrations/scope_guard.py +303 -0
- agent_os/integrations/semantic_kernel_adapter.py +769 -0
- agent_os/integrations/smolagents_adapter.py +629 -0
- agent_os/integrations/templates.py +178 -0
- agent_os/integrations/token_budget.py +134 -0
- agent_os/integrations/tool_aliases.py +190 -0
- agent_os/integrations/webhooks.py +177 -0
- agent_os/lite.py +208 -0
- agent_os/mcp_gateway.py +385 -0
- agent_os/mcp_message_signer.py +273 -0
- agent_os/mcp_protocols.py +161 -0
- agent_os/mcp_response_scanner.py +232 -0
- agent_os/mcp_security.py +924 -0
- agent_os/mcp_session_auth.py +231 -0
- agent_os/mcp_sliding_rate_limiter.py +184 -0
- agent_os/memory_guard.py +409 -0
- agent_os/metrics.py +134 -0
- agent_os/mute.py +428 -0
- agent_os/mute_agent.py +209 -0
- agent_os/policies/__init__.py +77 -0
- agent_os/policies/async_evaluator.py +275 -0
- agent_os/policies/backends.py +670 -0
- agent_os/policies/bridge.py +169 -0
- agent_os/policies/budget.py +85 -0
- agent_os/policies/cli.py +294 -0
- agent_os/policies/conflict_resolution.py +270 -0
- agent_os/policies/data_classification.py +252 -0
- agent_os/policies/evaluator.py +239 -0
- agent_os/policies/policy_schema.json +228 -0
- agent_os/policies/rate_limiting.py +145 -0
- agent_os/policies/schema.py +115 -0
- agent_os/policies/shared.py +331 -0
- agent_os/prompt_injection.py +694 -0
- agent_os/providers.py +182 -0
- agent_os/py.typed +0 -0
- agent_os/retry.py +81 -0
- agent_os/reversibility.py +251 -0
- agent_os/sandbox.py +432 -0
- agent_os/sandbox_provider.py +140 -0
- agent_os/secure_codegen.py +525 -0
- agent_os/security_skills.py +538 -0
- agent_os/semantic_policy.py +422 -0
- agent_os/server/__init__.py +15 -0
- agent_os/server/__main__.py +25 -0
- agent_os/server/app.py +277 -0
- agent_os/server/models.py +104 -0
- agent_os/shift_left_metrics.py +130 -0
- agent_os/stateless.py +742 -0
- agent_os/supervisor.py +148 -0
- agent_os/task_outcome.py +148 -0
- agent_os/transparency.py +181 -0
- agent_os/trust_root.py +128 -0
- agent_os_kernel-3.1.0.dist-info/METADATA +1269 -0
- agent_os_kernel-3.1.0.dist-info/RECORD +337 -0
- agent_os_kernel-3.1.0.dist-info/WHEEL +4 -0
- agent_os_kernel-3.1.0.dist-info/entry_points.txt +2 -0
- agent_os_kernel-3.1.0.dist-info/licenses/LICENSE +21 -0
- agent_os_observability/__init__.py +27 -0
- agent_os_observability/dashboards.py +898 -0
- agent_os_observability/metrics.py +398 -0
- agent_os_observability/server.py +223 -0
- agent_os_observability/tracer.py +232 -0
- agent_primitives/__init__.py +24 -0
- agent_primitives/failures.py +84 -0
- agent_primitives/py.typed +0 -0
- amb_core/__init__.py +177 -0
- amb_core/adapters/__init__.py +57 -0
- amb_core/adapters/aws_sqs_broker.py +376 -0
- amb_core/adapters/azure_servicebus_broker.py +340 -0
- amb_core/adapters/kafka_broker.py +260 -0
- amb_core/adapters/nats_broker.py +285 -0
- amb_core/adapters/rabbitmq_broker.py +235 -0
- amb_core/adapters/redis_broker.py +262 -0
- amb_core/broker.py +145 -0
- amb_core/bus.py +481 -0
- amb_core/cloudevents.py +509 -0
- amb_core/dlq.py +345 -0
- amb_core/hf_utils.py +536 -0
- amb_core/memory_broker.py +410 -0
- amb_core/models.py +141 -0
- amb_core/persistence.py +529 -0
- amb_core/schema.py +294 -0
- amb_core/tracing.py +358 -0
- atr/__init__.py +640 -0
- atr/access.py +348 -0
- atr/composition.py +645 -0
- atr/decorator.py +357 -0
- atr/executor.py +384 -0
- atr/health.py +557 -0
- atr/hf_utils.py +449 -0
- atr/injection.py +422 -0
- atr/metrics.py +440 -0
- atr/policies.py +403 -0
- atr/py.typed +2 -0
- atr/registry.py +452 -0
- atr/schema.py +480 -0
- atr/tools/safe/__init__.py +75 -0
- atr/tools/safe/calculator.py +467 -0
- atr/tools/safe/datetime_tool.py +443 -0
- atr/tools/safe/file_reader.py +402 -0
- atr/tools/safe/http_client.py +316 -0
- atr/tools/safe/json_parser.py +374 -0
- atr/tools/safe/text_tool.py +537 -0
- atr/tools/safe/toolkit.py +175 -0
- caas/__init__.py +162 -0
- caas/api/__init__.py +7 -0
- caas/api/server.py +1328 -0
- caas/caching.py +834 -0
- caas/cli.py +210 -0
- caas/conversation.py +223 -0
- caas/decay.py +72 -0
- caas/detection/__init__.py +9 -0
- caas/detection/detector.py +238 -0
- caas/enrichment.py +130 -0
- caas/gateway/__init__.py +27 -0
- caas/gateway/trust_gateway.py +474 -0
- caas/hf_utils.py +479 -0
- caas/ingestion/__init__.py +23 -0
- caas/ingestion/processors.py +253 -0
- caas/ingestion/structure_parser.py +188 -0
- caas/models.py +356 -0
- caas/pragmatic_truth.py +444 -0
- caas/routing/__init__.py +10 -0
- caas/routing/heuristic_router.py +58 -0
- caas/storage/__init__.py +9 -0
- caas/storage/store.py +389 -0
- caas/triad.py +213 -0
- caas/tuning/__init__.py +9 -0
- caas/tuning/tuner.py +329 -0
- caas/vfs/__init__.py +14 -0
- caas/vfs/filesystem.py +452 -0
- cmvk/__init__.py +218 -0
- cmvk/audit.py +402 -0
- cmvk/benchmarks.py +478 -0
- cmvk/constitutional.py +904 -0
- cmvk/hf_utils.py +301 -0
- cmvk/metrics.py +473 -0
- cmvk/profiles.py +300 -0
- cmvk/py.typed +0 -0
- cmvk/types.py +12 -0
- cmvk/verification.py +956 -0
- emk/__init__.py +89 -0
- emk/causal.py +352 -0
- emk/hf_utils.py +421 -0
- emk/indexer.py +83 -0
- emk/py.typed +0 -0
- emk/schema.py +204 -0
- emk/sleep_cycle.py +347 -0
- emk/store.py +281 -0
- iatp/__init__.py +166 -0
- iatp/attestation.py +461 -0
- iatp/cli.py +317 -0
- iatp/hf_utils.py +472 -0
- iatp/ipc_pipes.py +580 -0
- iatp/main.py +412 -0
- iatp/models/__init__.py +447 -0
- iatp/policy_engine.py +337 -0
- iatp/py.typed +2 -0
- iatp/recovery.py +321 -0
- iatp/security/__init__.py +270 -0
- iatp/sidecar/__init__.py +519 -0
- iatp/telemetry/__init__.py +164 -0
- iatp/tests/__init__.py +1 -0
- iatp/tests/test_attestation.py +370 -0
- iatp/tests/test_cli.py +131 -0
- iatp/tests/test_ed25519_attestation.py +211 -0
- iatp/tests/test_models.py +130 -0
- iatp/tests/test_policy_engine.py +347 -0
- iatp/tests/test_recovery.py +281 -0
- iatp/tests/test_security.py +222 -0
- iatp/tests/test_sidecar.py +167 -0
- iatp/tests/test_telemetry.py +175 -0
- mcp_kernel_server/__init__.py +28 -0
- mcp_kernel_server/cli.py +274 -0
- mcp_kernel_server/resources.py +217 -0
- mcp_kernel_server/server.py +564 -0
- mcp_kernel_server/tools.py +1174 -0
- mute_agent/__init__.py +68 -0
- mute_agent/core/__init__.py +1 -0
- mute_agent/core/execution_agent.py +166 -0
- mute_agent/core/handshake_protocol.py +201 -0
- mute_agent/core/reasoning_agent.py +238 -0
- mute_agent/knowledge_graph/__init__.py +1 -0
- mute_agent/knowledge_graph/graph_elements.py +65 -0
- mute_agent/knowledge_graph/multidimensional_graph.py +170 -0
- mute_agent/knowledge_graph/subgraph.py +224 -0
- mute_agent/listener/__init__.py +43 -0
- mute_agent/listener/adapters/__init__.py +31 -0
- mute_agent/listener/adapters/base_adapter.py +189 -0
- mute_agent/listener/adapters/caas_adapter.py +344 -0
- mute_agent/listener/adapters/control_plane_adapter.py +436 -0
- mute_agent/listener/adapters/iatp_adapter.py +332 -0
- mute_agent/listener/adapters/scak_adapter.py +251 -0
- mute_agent/listener/listener.py +610 -0
- mute_agent/listener/state_observer.py +436 -0
- mute_agent/listener/threshold_config.py +313 -0
- mute_agent/super_system/__init__.py +1 -0
- mute_agent/super_system/router.py +204 -0
- mute_agent/visualization/__init__.py +10 -0
- mute_agent/visualization/graph_debugger.py +502 -0
- nexus/README.md +60 -0
- nexus/__init__.py +51 -0
- nexus/arbiter.py +359 -0
- nexus/client.py +466 -0
- nexus/dmz.py +444 -0
- nexus/escrow.py +430 -0
- nexus/exceptions.py +286 -0
- nexus/pyproject.toml +36 -0
- nexus/registry.py +393 -0
- nexus/reputation.py +425 -0
- nexus/schemas/__init__.py +51 -0
- nexus/schemas/compliance.py +276 -0
- nexus/schemas/escrow.py +251 -0
- nexus/schemas/manifest.py +225 -0
- nexus/schemas/receipt.py +208 -0
- nexus/tests/__init__.py +0 -0
- nexus/tests/conftest.py +146 -0
- nexus/tests/test_arbiter.py +192 -0
- nexus/tests/test_dmz.py +194 -0
- nexus/tests/test_escrow.py +276 -0
- nexus/tests/test_exceptions.py +225 -0
- nexus/tests/test_registry.py +232 -0
- nexus/tests/test_reputation.py +328 -0
- 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
|