feat(safety): add Phase C advanced controls
- Add rollback manager with file checkpointing and transaction context - Add HITL manager with approval queues and notification handlers - Add content filter with PII, secrets, and injection detection - Add emergency controls with stop/pause/resume capabilities - Update SafetyConfig with checkpoint_dir setting Issue #63 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
449
backend/app/services/safety/hitl/manager.py
Normal file
449
backend/app/services/safety/hitl/manager.py
Normal file
@@ -0,0 +1,449 @@
|
||||
"""
|
||||
Human-in-the-Loop (HITL) Manager
|
||||
|
||||
Manages approval workflows for actions requiring human oversight.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from ..config import get_safety_config
|
||||
from ..exceptions import (
|
||||
ApprovalDeniedError,
|
||||
ApprovalRequiredError,
|
||||
ApprovalTimeoutError,
|
||||
)
|
||||
from ..models import (
|
||||
ActionRequest,
|
||||
ApprovalRequest,
|
||||
ApprovalResponse,
|
||||
ApprovalStatus,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ApprovalQueue:
|
||||
"""Queue for pending approval requests."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pending: dict[str, ApprovalRequest] = {}
|
||||
self._completed: dict[str, ApprovalResponse] = {}
|
||||
self._waiters: dict[str, asyncio.Event] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def add(self, request: ApprovalRequest) -> None:
|
||||
"""Add an approval request to the queue."""
|
||||
async with self._lock:
|
||||
self._pending[request.id] = request
|
||||
self._waiters[request.id] = asyncio.Event()
|
||||
|
||||
async def get_pending(self, request_id: str) -> ApprovalRequest | None:
|
||||
"""Get a pending request by ID."""
|
||||
async with self._lock:
|
||||
return self._pending.get(request_id)
|
||||
|
||||
async def complete(self, response: ApprovalResponse) -> bool:
|
||||
"""Complete an approval request."""
|
||||
async with self._lock:
|
||||
if response.request_id not in self._pending:
|
||||
return False
|
||||
|
||||
del self._pending[response.request_id]
|
||||
self._completed[response.request_id] = response
|
||||
|
||||
# Notify waiters
|
||||
if response.request_id in self._waiters:
|
||||
self._waiters[response.request_id].set()
|
||||
|
||||
return True
|
||||
|
||||
async def wait_for_response(
|
||||
self,
|
||||
request_id: str,
|
||||
timeout_seconds: float,
|
||||
) -> ApprovalResponse | None:
|
||||
"""Wait for a response to an approval request."""
|
||||
async with self._lock:
|
||||
waiter = self._waiters.get(request_id)
|
||||
if not waiter:
|
||||
return self._completed.get(request_id)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(waiter.wait(), timeout=timeout_seconds)
|
||||
except TimeoutError:
|
||||
return None
|
||||
|
||||
async with self._lock:
|
||||
return self._completed.get(request_id)
|
||||
|
||||
async def list_pending(self) -> list[ApprovalRequest]:
|
||||
"""List all pending requests."""
|
||||
async with self._lock:
|
||||
return list(self._pending.values())
|
||||
|
||||
async def cancel(self, request_id: str) -> bool:
|
||||
"""Cancel a pending request."""
|
||||
async with self._lock:
|
||||
if request_id not in self._pending:
|
||||
return False
|
||||
|
||||
del self._pending[request_id]
|
||||
|
||||
# Create cancelled response
|
||||
response = ApprovalResponse(
|
||||
request_id=request_id,
|
||||
status=ApprovalStatus.CANCELLED,
|
||||
reason="Cancelled",
|
||||
)
|
||||
self._completed[request_id] = response
|
||||
|
||||
# Notify waiters
|
||||
if request_id in self._waiters:
|
||||
self._waiters[request_id].set()
|
||||
|
||||
return True
|
||||
|
||||
async def cleanup_expired(self) -> int:
|
||||
"""Clean up expired requests."""
|
||||
now = datetime.utcnow()
|
||||
to_timeout: list[str] = []
|
||||
|
||||
async with self._lock:
|
||||
for request_id, request in self._pending.items():
|
||||
if request.expires_at and request.expires_at < now:
|
||||
to_timeout.append(request_id)
|
||||
|
||||
count = 0
|
||||
for request_id in to_timeout:
|
||||
async with self._lock:
|
||||
if request_id in self._pending:
|
||||
del self._pending[request_id]
|
||||
self._completed[request_id] = ApprovalResponse(
|
||||
request_id=request_id,
|
||||
status=ApprovalStatus.TIMEOUT,
|
||||
reason="Request timed out",
|
||||
)
|
||||
if request_id in self._waiters:
|
||||
self._waiters[request_id].set()
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
|
||||
class HITLManager:
|
||||
"""
|
||||
Manages Human-in-the-Loop approval workflows.
|
||||
|
||||
Features:
|
||||
- Approval request queue
|
||||
- Configurable timeout handling (default deny)
|
||||
- Approval delegation
|
||||
- Batch approval for similar actions
|
||||
- Approval with modifications
|
||||
- Notification channels
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
default_timeout: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the HITLManager.
|
||||
|
||||
Args:
|
||||
default_timeout: Default timeout for approval requests in seconds
|
||||
"""
|
||||
config = get_safety_config()
|
||||
|
||||
self._default_timeout = default_timeout or config.hitl_default_timeout
|
||||
self._queue = ApprovalQueue()
|
||||
self._notification_handlers: list[Callable[..., Any]] = []
|
||||
self._running = False
|
||||
self._cleanup_task: asyncio.Task[None] | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the HITL manager background tasks."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._cleanup_task = asyncio.create_task(self._periodic_cleanup())
|
||||
logger.info("HITL Manager started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the HITL manager."""
|
||||
self._running = False
|
||||
|
||||
if self._cleanup_task:
|
||||
self._cleanup_task.cancel()
|
||||
try:
|
||||
await self._cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
logger.info("HITL Manager stopped")
|
||||
|
||||
async def request_approval(
|
||||
self,
|
||||
action: ActionRequest,
|
||||
reason: str,
|
||||
timeout_seconds: int | None = None,
|
||||
urgency: str = "normal",
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> ApprovalRequest:
|
||||
"""
|
||||
Create an approval request for an action.
|
||||
|
||||
Args:
|
||||
action: The action requiring approval
|
||||
reason: Why approval is required
|
||||
timeout_seconds: Timeout for this request
|
||||
urgency: Urgency level (low, normal, high, critical)
|
||||
context: Additional context for the approver
|
||||
|
||||
Returns:
|
||||
The created approval request
|
||||
"""
|
||||
timeout = timeout_seconds or self._default_timeout
|
||||
expires_at = datetime.utcnow() + timedelta(seconds=timeout)
|
||||
|
||||
request = ApprovalRequest(
|
||||
id=str(uuid4()),
|
||||
action=action,
|
||||
reason=reason,
|
||||
urgency=urgency,
|
||||
timeout_seconds=timeout,
|
||||
expires_at=expires_at,
|
||||
context=context or {},
|
||||
)
|
||||
|
||||
await self._queue.add(request)
|
||||
|
||||
# Notify handlers
|
||||
await self._notify_handlers("approval_requested", request)
|
||||
|
||||
logger.info(
|
||||
"Approval requested: %s for action %s (timeout: %ds)",
|
||||
request.id,
|
||||
action.id,
|
||||
timeout,
|
||||
)
|
||||
|
||||
return request
|
||||
|
||||
async def wait_for_approval(
|
||||
self,
|
||||
request_id: str,
|
||||
timeout_seconds: int | None = None,
|
||||
) -> ApprovalResponse:
|
||||
"""
|
||||
Wait for an approval decision.
|
||||
|
||||
Args:
|
||||
request_id: ID of the approval request
|
||||
timeout_seconds: Override timeout
|
||||
|
||||
Returns:
|
||||
The approval response
|
||||
|
||||
Raises:
|
||||
ApprovalTimeoutError: If timeout expires
|
||||
ApprovalDeniedError: If approval is denied
|
||||
"""
|
||||
request = await self._queue.get_pending(request_id)
|
||||
if not request:
|
||||
raise ApprovalRequiredError(
|
||||
f"Approval request not found: {request_id}",
|
||||
approval_id=request_id,
|
||||
)
|
||||
|
||||
timeout = timeout_seconds or request.timeout_seconds or self._default_timeout
|
||||
response = await self._queue.wait_for_response(request_id, timeout)
|
||||
|
||||
if response is None:
|
||||
# Timeout - default deny
|
||||
response = ApprovalResponse(
|
||||
request_id=request_id,
|
||||
status=ApprovalStatus.TIMEOUT,
|
||||
reason="Request timed out (default deny)",
|
||||
)
|
||||
await self._queue.complete(response)
|
||||
|
||||
raise ApprovalTimeoutError(
|
||||
"Approval request timed out",
|
||||
approval_id=request_id,
|
||||
timeout_seconds=timeout,
|
||||
)
|
||||
|
||||
if response.status == ApprovalStatus.DENIED:
|
||||
raise ApprovalDeniedError(
|
||||
response.reason or "Approval denied",
|
||||
approval_id=request_id,
|
||||
denied_by=response.decided_by,
|
||||
denial_reason=response.reason,
|
||||
)
|
||||
|
||||
if response.status == ApprovalStatus.TIMEOUT:
|
||||
raise ApprovalTimeoutError(
|
||||
"Approval request timed out",
|
||||
approval_id=request_id,
|
||||
timeout_seconds=timeout,
|
||||
)
|
||||
|
||||
if response.status == ApprovalStatus.CANCELLED:
|
||||
raise ApprovalDeniedError(
|
||||
"Approval request was cancelled",
|
||||
approval_id=request_id,
|
||||
denial_reason="Cancelled",
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
async def approve(
|
||||
self,
|
||||
request_id: str,
|
||||
decided_by: str,
|
||||
reason: str | None = None,
|
||||
modifications: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Approve a pending request.
|
||||
|
||||
Args:
|
||||
request_id: ID of the approval request
|
||||
decided_by: Who approved
|
||||
reason: Optional approval reason
|
||||
modifications: Optional modifications to the action
|
||||
|
||||
Returns:
|
||||
True if approval was recorded
|
||||
"""
|
||||
response = ApprovalResponse(
|
||||
request_id=request_id,
|
||||
status=ApprovalStatus.APPROVED,
|
||||
decided_by=decided_by,
|
||||
reason=reason,
|
||||
modifications=modifications,
|
||||
)
|
||||
|
||||
success = await self._queue.complete(response)
|
||||
|
||||
if success:
|
||||
logger.info(
|
||||
"Approval granted: %s by %s",
|
||||
request_id,
|
||||
decided_by,
|
||||
)
|
||||
await self._notify_handlers("approval_granted", response)
|
||||
|
||||
return success
|
||||
|
||||
async def deny(
|
||||
self,
|
||||
request_id: str,
|
||||
decided_by: str,
|
||||
reason: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Deny a pending request.
|
||||
|
||||
Args:
|
||||
request_id: ID of the approval request
|
||||
decided_by: Who denied
|
||||
reason: Denial reason
|
||||
|
||||
Returns:
|
||||
True if denial was recorded
|
||||
"""
|
||||
response = ApprovalResponse(
|
||||
request_id=request_id,
|
||||
status=ApprovalStatus.DENIED,
|
||||
decided_by=decided_by,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
success = await self._queue.complete(response)
|
||||
|
||||
if success:
|
||||
logger.info(
|
||||
"Approval denied: %s by %s - %s",
|
||||
request_id,
|
||||
decided_by,
|
||||
reason,
|
||||
)
|
||||
await self._notify_handlers("approval_denied", response)
|
||||
|
||||
return success
|
||||
|
||||
async def cancel(self, request_id: str) -> bool:
|
||||
"""
|
||||
Cancel a pending request.
|
||||
|
||||
Args:
|
||||
request_id: ID of the approval request
|
||||
|
||||
Returns:
|
||||
True if request was cancelled
|
||||
"""
|
||||
success = await self._queue.cancel(request_id)
|
||||
|
||||
if success:
|
||||
logger.info("Approval request cancelled: %s", request_id)
|
||||
|
||||
return success
|
||||
|
||||
async def list_pending(self) -> list[ApprovalRequest]:
|
||||
"""List all pending approval requests."""
|
||||
return await self._queue.list_pending()
|
||||
|
||||
async def get_request(self, request_id: str) -> ApprovalRequest | None:
|
||||
"""Get an approval request by ID."""
|
||||
return await self._queue.get_pending(request_id)
|
||||
|
||||
def add_notification_handler(
|
||||
self,
|
||||
handler: Callable[..., Any],
|
||||
) -> None:
|
||||
"""Add a notification handler."""
|
||||
self._notification_handlers.append(handler)
|
||||
|
||||
def remove_notification_handler(
|
||||
self,
|
||||
handler: Callable[..., Any],
|
||||
) -> None:
|
||||
"""Remove a notification handler."""
|
||||
if handler in self._notification_handlers:
|
||||
self._notification_handlers.remove(handler)
|
||||
|
||||
async def _notify_handlers(
|
||||
self,
|
||||
event_type: str,
|
||||
data: Any,
|
||||
) -> None:
|
||||
"""Notify all handlers of an event."""
|
||||
for handler in self._notification_handlers:
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(handler):
|
||||
await handler(event_type, data)
|
||||
else:
|
||||
handler(event_type, data)
|
||||
except Exception as e:
|
||||
logger.error("Error in notification handler: %s", e)
|
||||
|
||||
async def _periodic_cleanup(self) -> None:
|
||||
"""Background task for cleaning up expired requests."""
|
||||
while self._running:
|
||||
try:
|
||||
await asyncio.sleep(30) # Check every 30 seconds
|
||||
count = await self._queue.cleanup_expired()
|
||||
if count:
|
||||
logger.debug("Cleaned up %d expired approval requests", count)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in approval cleanup: %s", e)
|
||||
Reference in New Issue
Block a user