agenthandler 0.92.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.
Potentially problematic release.
This version of agenthandler might be problematic. Click here for more details.
- agenthandler/__init__.py +163 -0
- agenthandler/approval.py +135 -0
- agenthandler/audit.py +144 -0
- agenthandler/auth.py +188 -0
- agenthandler/budget.py +94 -0
- agenthandler/builder.py +178 -0
- agenthandler/circuit_breaker.py +88 -0
- agenthandler/connectors.py +425 -0
- agenthandler/context.py +410 -0
- agenthandler/errors.py +130 -0
- agenthandler/guardrails/__init__.py +79 -0
- agenthandler/guardrails/deterministic.py +472 -0
- agenthandler/guardrails/llm_based.py +549 -0
- agenthandler/guardrails/types.py +83 -0
- agenthandler/observe.py +525 -0
- agenthandler/openclaw.py +370 -0
- agenthandler/pipeline.py +532 -0
- agenthandler/pipeline_poml.py +593 -0
- agenthandler/policy.py +251 -0
- agenthandler/py.typed +0 -0
- agenthandler/redact.py +260 -0
- agenthandler/reflection.py +371 -0
- agenthandler/replay.py +225 -0
- agenthandler/server.py +752 -0
- agenthandler/session.py +391 -0
- agenthandler/skill.py +265 -0
- agenthandler/store.py +341 -0
- agenthandler/streams.py +339 -0
- agenthandler/supervisor.py +882 -0
- agenthandler/triggers.py +471 -0
- agenthandler-0.92.0.dist-info/METADATA +1355 -0
- agenthandler-0.92.0.dist-info/RECORD +34 -0
- agenthandler-0.92.0.dist-info/WHEEL +4 -0
- agenthandler-0.92.0.dist-info/licenses/LICENSE +201 -0
agenthandler/__init__.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""AgentHandler — supervision for AI agent tool execution.
|
|
2
|
+
|
|
3
|
+
Timeouts, budgets, circuit breakers, and audit for tool calls.
|
|
4
|
+
Works with any agent framework — just wrap your tool calls.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from agenthandler import Supervisor, Policy
|
|
8
|
+
|
|
9
|
+
policy = Policy(max_iterations=3, tool_timeout=15, token_budget=10000)
|
|
10
|
+
supervisor = Supervisor(policy)
|
|
11
|
+
|
|
12
|
+
# Supervise a tool call
|
|
13
|
+
result = await supervisor.call("web_search", my_tool_fn, query="python async")
|
|
14
|
+
if result.succeeded:
|
|
15
|
+
print(result.output)
|
|
16
|
+
else:
|
|
17
|
+
print(result.error)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
__version__ = "0.92.0"
|
|
21
|
+
|
|
22
|
+
from .approval import ApprovalQueue, ApprovalRequest, ApprovalStatus
|
|
23
|
+
from .audit import AuditEntry, AuditLog, AuditOutcome, AuditPhase, AuditSink, JsonlAuditSink
|
|
24
|
+
from .budget import BudgetSnapshot, BudgetTracker
|
|
25
|
+
from .builder import SkillBuilder
|
|
26
|
+
from .circuit_breaker import CircuitBreaker, CircuitState
|
|
27
|
+
from .connectors import MongoConnector, SqlConnector
|
|
28
|
+
from .context import AnchoredState, CompressedContext, CompressedHistory, ContextWindow, Turn
|
|
29
|
+
from .errors import AgentHandlerError
|
|
30
|
+
from .guardrails import (
|
|
31
|
+
ActiveHours,
|
|
32
|
+
AnomalyScorer,
|
|
33
|
+
CategoryRateLimit,
|
|
34
|
+
EnsembleDecision,
|
|
35
|
+
ExplanationRequired,
|
|
36
|
+
GoalVerifier,
|
|
37
|
+
GuardrailResult,
|
|
38
|
+
IdempotencyGuard,
|
|
39
|
+
IntentDriftDetector,
|
|
40
|
+
OutputLengthLimit,
|
|
41
|
+
PromptInjectionDetector,
|
|
42
|
+
RecursionDetector,
|
|
43
|
+
ResourceQuota,
|
|
44
|
+
SafetyClassifier,
|
|
45
|
+
SchemaValidator,
|
|
46
|
+
SemanticPiiRedactor,
|
|
47
|
+
UrlGuard,
|
|
48
|
+
)
|
|
49
|
+
from .observe import ModelPricing, Observer, PricingTable, ToolCallRecord, ToolMetrics
|
|
50
|
+
from .openclaw import OpenClawBridge, to_openclaw_manifest, to_openclaw_tool_schema
|
|
51
|
+
from .pipeline import Pipeline, PipelineResult
|
|
52
|
+
from .pipeline_poml import PipelineBuilder, parse_pipeline_poml
|
|
53
|
+
from .policy import Action, OnFailure, Policy, ScopeRule, ToolScope
|
|
54
|
+
from .redact import Redactor
|
|
55
|
+
from .reflection import ReflectionCycle, ReflectionLoop, ReflectionResult
|
|
56
|
+
from .replay import ReplayFrame, ReplaySummary, SessionReplay
|
|
57
|
+
from .session import SessionManager
|
|
58
|
+
from .skill import Skill, SkillStore, ToolDefinition, ToolParameter
|
|
59
|
+
from .store import (
|
|
60
|
+
Checkpoint,
|
|
61
|
+
MemoryStore,
|
|
62
|
+
SessionStatus,
|
|
63
|
+
SqliteStore,
|
|
64
|
+
StateStore,
|
|
65
|
+
validate_payload,
|
|
66
|
+
)
|
|
67
|
+
from .streams import (
|
|
68
|
+
KafkaStreamConnector,
|
|
69
|
+
KinesisStreamConnector,
|
|
70
|
+
RedisStreamConnector,
|
|
71
|
+
StreamConnector,
|
|
72
|
+
)
|
|
73
|
+
from .supervisor import SupervisedResult, Supervisor
|
|
74
|
+
from .triggers import DbWatchEntry, Scheduler, WebhookTrigger
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
"Policy",
|
|
78
|
+
"OnFailure",
|
|
79
|
+
"Action",
|
|
80
|
+
"ScopeRule",
|
|
81
|
+
"ToolScope",
|
|
82
|
+
"Supervisor",
|
|
83
|
+
"SupervisedResult",
|
|
84
|
+
"BudgetTracker",
|
|
85
|
+
"BudgetSnapshot",
|
|
86
|
+
"CircuitBreaker",
|
|
87
|
+
"CircuitState",
|
|
88
|
+
"AuditLog",
|
|
89
|
+
"AuditEntry",
|
|
90
|
+
"AuditPhase",
|
|
91
|
+
"AuditOutcome",
|
|
92
|
+
"AuditSink",
|
|
93
|
+
"JsonlAuditSink",
|
|
94
|
+
"AgentHandlerError",
|
|
95
|
+
"Skill",
|
|
96
|
+
"SkillStore",
|
|
97
|
+
"ToolDefinition",
|
|
98
|
+
"ToolParameter",
|
|
99
|
+
"SkillBuilder",
|
|
100
|
+
"ContextWindow",
|
|
101
|
+
"CompressedContext",
|
|
102
|
+
"CompressedHistory",
|
|
103
|
+
"AnchoredState",
|
|
104
|
+
"Turn",
|
|
105
|
+
"OpenClawBridge",
|
|
106
|
+
"to_openclaw_tool_schema",
|
|
107
|
+
"to_openclaw_manifest",
|
|
108
|
+
"StateStore",
|
|
109
|
+
"MemoryStore",
|
|
110
|
+
"SqliteStore",
|
|
111
|
+
"Checkpoint",
|
|
112
|
+
"SessionStatus",
|
|
113
|
+
"SessionManager",
|
|
114
|
+
"validate_payload",
|
|
115
|
+
"ApprovalQueue",
|
|
116
|
+
"ApprovalRequest",
|
|
117
|
+
"ApprovalStatus",
|
|
118
|
+
"Redactor",
|
|
119
|
+
"Observer",
|
|
120
|
+
"ToolCallRecord",
|
|
121
|
+
"ToolMetrics",
|
|
122
|
+
"PricingTable",
|
|
123
|
+
"ModelPricing",
|
|
124
|
+
"Pipeline",
|
|
125
|
+
"PipelineResult",
|
|
126
|
+
"PipelineBuilder",
|
|
127
|
+
"parse_pipeline_poml",
|
|
128
|
+
"SqlConnector",
|
|
129
|
+
"MongoConnector",
|
|
130
|
+
"Scheduler",
|
|
131
|
+
"WebhookTrigger",
|
|
132
|
+
"DbWatchEntry",
|
|
133
|
+
"StreamConnector",
|
|
134
|
+
"KafkaStreamConnector",
|
|
135
|
+
"KinesisStreamConnector",
|
|
136
|
+
"RedisStreamConnector",
|
|
137
|
+
# Guardrails — deterministic
|
|
138
|
+
"GuardrailResult",
|
|
139
|
+
"SchemaValidator",
|
|
140
|
+
"CategoryRateLimit",
|
|
141
|
+
"RecursionDetector",
|
|
142
|
+
"IdempotencyGuard",
|
|
143
|
+
"ResourceQuota",
|
|
144
|
+
"OutputLengthLimit",
|
|
145
|
+
"UrlGuard",
|
|
146
|
+
"ActiveHours",
|
|
147
|
+
# Guardrails — LLM-based
|
|
148
|
+
"PromptInjectionDetector",
|
|
149
|
+
"SafetyClassifier",
|
|
150
|
+
"IntentDriftDetector",
|
|
151
|
+
"SemanticPiiRedactor",
|
|
152
|
+
"AnomalyScorer",
|
|
153
|
+
"GoalVerifier",
|
|
154
|
+
"ExplanationRequired",
|
|
155
|
+
"EnsembleDecision",
|
|
156
|
+
# Long-duration features
|
|
157
|
+
"ReflectionLoop",
|
|
158
|
+
"ReflectionCycle",
|
|
159
|
+
"ReflectionResult",
|
|
160
|
+
"SessionReplay",
|
|
161
|
+
"ReplayFrame",
|
|
162
|
+
"ReplaySummary",
|
|
163
|
+
]
|
agenthandler/approval.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Approval queue — human-in-the-loop confirmation for dangerous tool calls.
|
|
2
|
+
|
|
3
|
+
When a tool is in the policy's require_confirm list, the Supervisor queues
|
|
4
|
+
the call here instead of blocking it outright. The call stays pending until
|
|
5
|
+
a human approves or denies it via the REST API, dashboard, or any client
|
|
6
|
+
listening on the WebSocket events stream.
|
|
7
|
+
|
|
8
|
+
Usage (internal — SessionManager wires this up automatically):
|
|
9
|
+
|
|
10
|
+
queue = ApprovalQueue()
|
|
11
|
+
approval_id = queue.submit("delete_file", {"path": "/tmp/data"}, session_id)
|
|
12
|
+
# ... user reviews and approves via REST ...
|
|
13
|
+
queue.approve(approval_id)
|
|
14
|
+
# ... or denies ...
|
|
15
|
+
queue.deny(approval_id, reason="Too risky")
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import secrets
|
|
19
|
+
import threading
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from enum import Enum
|
|
23
|
+
from typing import Any, Dict, List, Optional
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ApprovalStatus(Enum):
|
|
27
|
+
PENDING = "pending"
|
|
28
|
+
APPROVED = "approved"
|
|
29
|
+
DENIED = "denied"
|
|
30
|
+
EXPIRED = "expired"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ApprovalRequest:
|
|
35
|
+
"""A pending tool call awaiting human approval."""
|
|
36
|
+
|
|
37
|
+
approval_id: str
|
|
38
|
+
session_id: str
|
|
39
|
+
tool_name: str
|
|
40
|
+
tool_args: Dict[str, Any]
|
|
41
|
+
status: ApprovalStatus = ApprovalStatus.PENDING
|
|
42
|
+
created_at: str = ""
|
|
43
|
+
resolved_at: str = ""
|
|
44
|
+
resolved_by: str = "" # who approved/denied (for audit)
|
|
45
|
+
deny_reason: str = ""
|
|
46
|
+
|
|
47
|
+
def __post_init__(self) -> None:
|
|
48
|
+
if not self.created_at:
|
|
49
|
+
self.created_at = datetime.now(timezone.utc).isoformat()
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
52
|
+
return {
|
|
53
|
+
"approval_id": self.approval_id,
|
|
54
|
+
"session_id": self.session_id,
|
|
55
|
+
"tool_name": self.tool_name,
|
|
56
|
+
"tool_args": {k: str(v)[:200] for k, v in self.tool_args.items()},
|
|
57
|
+
"status": self.status.value,
|
|
58
|
+
"created_at": self.created_at,
|
|
59
|
+
"resolved_at": self.resolved_at,
|
|
60
|
+
"resolved_by": self.resolved_by,
|
|
61
|
+
"deny_reason": self.deny_reason,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ApprovalQueue:
|
|
66
|
+
"""Thread-safe queue of pending tool call approvals.
|
|
67
|
+
|
|
68
|
+
One queue per SessionManager — holds approvals for all sessions.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self) -> None:
|
|
72
|
+
self._requests: Dict[str, ApprovalRequest] = {}
|
|
73
|
+
self._lock = threading.Lock()
|
|
74
|
+
|
|
75
|
+
def submit(
|
|
76
|
+
self,
|
|
77
|
+
tool_name: str,
|
|
78
|
+
tool_args: Dict[str, Any],
|
|
79
|
+
session_id: str,
|
|
80
|
+
) -> ApprovalRequest:
|
|
81
|
+
"""Queue a tool call for approval. Returns the ApprovalRequest."""
|
|
82
|
+
req = ApprovalRequest(
|
|
83
|
+
approval_id=secrets.token_hex(8),
|
|
84
|
+
session_id=session_id,
|
|
85
|
+
tool_name=tool_name,
|
|
86
|
+
tool_args=tool_args,
|
|
87
|
+
)
|
|
88
|
+
with self._lock:
|
|
89
|
+
self._requests[req.approval_id] = req
|
|
90
|
+
return req
|
|
91
|
+
|
|
92
|
+
def get(self, approval_id: str) -> Optional[ApprovalRequest]:
|
|
93
|
+
with self._lock:
|
|
94
|
+
return self._requests.get(approval_id)
|
|
95
|
+
|
|
96
|
+
def approve(self, approval_id: str, approved_by: str = "") -> Optional[ApprovalRequest]:
|
|
97
|
+
"""Approve a pending request. Returns the request, or None if not found."""
|
|
98
|
+
with self._lock:
|
|
99
|
+
req = self._requests.get(approval_id)
|
|
100
|
+
if req is None or req.status != ApprovalStatus.PENDING:
|
|
101
|
+
return None
|
|
102
|
+
req.status = ApprovalStatus.APPROVED
|
|
103
|
+
req.resolved_at = datetime.now(timezone.utc).isoformat()
|
|
104
|
+
req.resolved_by = approved_by
|
|
105
|
+
return req
|
|
106
|
+
|
|
107
|
+
def deny(
|
|
108
|
+
self, approval_id: str, reason: str = "", denied_by: str = ""
|
|
109
|
+
) -> Optional[ApprovalRequest]:
|
|
110
|
+
"""Deny a pending request. Returns the request, or None if not found."""
|
|
111
|
+
with self._lock:
|
|
112
|
+
req = self._requests.get(approval_id)
|
|
113
|
+
if req is None or req.status != ApprovalStatus.PENDING:
|
|
114
|
+
return None
|
|
115
|
+
req.status = ApprovalStatus.DENIED
|
|
116
|
+
req.deny_reason = reason
|
|
117
|
+
req.resolved_at = datetime.now(timezone.utc).isoformat()
|
|
118
|
+
req.resolved_by = denied_by
|
|
119
|
+
return req
|
|
120
|
+
|
|
121
|
+
def list_pending(self, session_id: Optional[str] = None) -> List[ApprovalRequest]:
|
|
122
|
+
"""List pending approvals, optionally filtered by session."""
|
|
123
|
+
with self._lock:
|
|
124
|
+
reqs = list(self._requests.values())
|
|
125
|
+
if session_id is not None:
|
|
126
|
+
reqs = [r for r in reqs if r.session_id == session_id]
|
|
127
|
+
return [r for r in reqs if r.status == ApprovalStatus.PENDING]
|
|
128
|
+
|
|
129
|
+
def list_all(self, session_id: Optional[str] = None) -> List[ApprovalRequest]:
|
|
130
|
+
"""List all approvals (any status), optionally filtered by session."""
|
|
131
|
+
with self._lock:
|
|
132
|
+
reqs = list(self._requests.values())
|
|
133
|
+
if session_id is not None:
|
|
134
|
+
reqs = [r for r in reqs if r.session_id == session_id]
|
|
135
|
+
return reqs
|
agenthandler/audit.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Audit logging — every supervised action gets recorded."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import IO, Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuditPhase(Enum):
|
|
15
|
+
TOOL_CALL = "tool_call"
|
|
16
|
+
BUDGET_CHECK = "budget_check"
|
|
17
|
+
CIRCUIT_BREAKER = "circuit_breaker"
|
|
18
|
+
ITERATION_CHECK = "iteration_check"
|
|
19
|
+
DEAD_MAN_SWITCH = "dead_man_switch"
|
|
20
|
+
POLICY_CHECK = "policy_check"
|
|
21
|
+
SCOPE_CHECK = "scope_check"
|
|
22
|
+
REQUEST_START = "request_start"
|
|
23
|
+
REQUEST_END = "request_end"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AuditOutcome(Enum):
|
|
27
|
+
ALLOWED = "allowed"
|
|
28
|
+
DENIED = "denied"
|
|
29
|
+
TIMED_OUT = "timed_out"
|
|
30
|
+
FAILED = "failed"
|
|
31
|
+
BUDGET_EXCEEDED = "budget_exceeded"
|
|
32
|
+
RETRIED = "retried"
|
|
33
|
+
INFO = "info"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class AuditEntry:
|
|
38
|
+
"""A single audit log entry."""
|
|
39
|
+
|
|
40
|
+
timestamp: str
|
|
41
|
+
request_id: str
|
|
42
|
+
phase: str
|
|
43
|
+
outcome: str
|
|
44
|
+
target: Optional[str] = None
|
|
45
|
+
detail: Optional[str] = None
|
|
46
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
49
|
+
d = asdict(self)
|
|
50
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
51
|
+
|
|
52
|
+
def to_json(self) -> str:
|
|
53
|
+
return json.dumps(self.to_dict(), default=str)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AuditSink(ABC):
|
|
57
|
+
"""Interface for audit log destinations."""
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def write(self, entry: AuditEntry) -> None: ...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class JsonlAuditSink(AuditSink):
|
|
64
|
+
"""Writes audit entries as JSONL to a file or stream."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, writer: IO[str]):
|
|
67
|
+
self._writer = writer
|
|
68
|
+
self._lock = threading.Lock()
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def stdout(cls) -> "JsonlAuditSink":
|
|
72
|
+
return cls(sys.stdout)
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def file(cls, path: str) -> "JsonlAuditSink":
|
|
76
|
+
p = Path(path)
|
|
77
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
return cls(open(p, "a"))
|
|
79
|
+
|
|
80
|
+
def write(self, entry: AuditEntry) -> None:
|
|
81
|
+
with self._lock:
|
|
82
|
+
self._writer.write(entry.to_json() + "\n")
|
|
83
|
+
self._writer.flush()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class MemoryAuditSink(AuditSink):
|
|
87
|
+
"""In-memory audit sink for testing."""
|
|
88
|
+
|
|
89
|
+
def __init__(self) -> None:
|
|
90
|
+
self._entries: List[AuditEntry] = []
|
|
91
|
+
self._lock = threading.Lock()
|
|
92
|
+
|
|
93
|
+
def write(self, entry: AuditEntry) -> None:
|
|
94
|
+
with self._lock:
|
|
95
|
+
self._entries.append(entry)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def entries(self) -> List[AuditEntry]:
|
|
99
|
+
with self._lock:
|
|
100
|
+
return list(self._entries)
|
|
101
|
+
|
|
102
|
+
def __len__(self) -> int:
|
|
103
|
+
with self._lock:
|
|
104
|
+
return len(self._entries)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class AuditLog:
|
|
108
|
+
"""Records audit entries for a single request."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, request_id: str, sinks: Optional[List[AuditSink]] = None):
|
|
111
|
+
self._request_id = request_id
|
|
112
|
+
self._sinks = sinks or []
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def noop(cls) -> "AuditLog":
|
|
116
|
+
"""Audit log that discards all entries."""
|
|
117
|
+
return cls("noop", [])
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def request_id(self) -> str:
|
|
121
|
+
return self._request_id
|
|
122
|
+
|
|
123
|
+
def record(
|
|
124
|
+
self,
|
|
125
|
+
phase: AuditPhase,
|
|
126
|
+
outcome: AuditOutcome,
|
|
127
|
+
target: Optional[str] = None,
|
|
128
|
+
detail: Optional[str] = None,
|
|
129
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
130
|
+
) -> None:
|
|
131
|
+
entry = AuditEntry(
|
|
132
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
133
|
+
request_id=self._request_id,
|
|
134
|
+
phase=phase.value,
|
|
135
|
+
outcome=outcome.value,
|
|
136
|
+
target=target,
|
|
137
|
+
detail=detail,
|
|
138
|
+
metadata=metadata,
|
|
139
|
+
)
|
|
140
|
+
for sink in self._sinks:
|
|
141
|
+
try:
|
|
142
|
+
sink.write(entry)
|
|
143
|
+
except Exception:
|
|
144
|
+
pass # audit must never crash the request
|
agenthandler/auth.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Authentication providers for the REST control plane.
|
|
2
|
+
|
|
3
|
+
Supports:
|
|
4
|
+
- API key (Bearer token) — simple, self-issued
|
|
5
|
+
- OAuth2 (GitHub, Google) — for production deployments
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
# API key only
|
|
9
|
+
app = create_app(api_key="my-secret")
|
|
10
|
+
|
|
11
|
+
# OAuth2 via env vars:
|
|
12
|
+
# AGENTHANDLER_OAUTH_PROVIDER=github
|
|
13
|
+
# AGENTHANDLER_OAUTH_CLIENT_ID=...
|
|
14
|
+
# AGENTHANDLER_OAUTH_CLIENT_SECRET=...
|
|
15
|
+
app = create_app()
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import secrets
|
|
21
|
+
import time
|
|
22
|
+
from typing import Any, Dict, Optional
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from fastapi import Depends, FastAPI, HTTPException, Request
|
|
26
|
+
from fastapi.security import (
|
|
27
|
+
HTTPAuthorizationCredentials,
|
|
28
|
+
HTTPBearer,
|
|
29
|
+
)
|
|
30
|
+
except ImportError:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_bearer_scheme = HTTPBearer(auto_error=False)
|
|
35
|
+
|
|
36
|
+
# In-memory token store for OAuth sessions (maps token -> user info + expiry)
|
|
37
|
+
_oauth_tokens: Dict[str, Dict[str, Any]] = {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def make_auth_dependency(
|
|
41
|
+
api_key: Optional[str] = None,
|
|
42
|
+
oauth_enabled: bool = False,
|
|
43
|
+
) -> Any:
|
|
44
|
+
"""Build a FastAPI dependency that enforces auth.
|
|
45
|
+
|
|
46
|
+
Checks in order:
|
|
47
|
+
1. Bearer token matches api_key (if set)
|
|
48
|
+
2. Bearer token is a valid OAuth session token (if oauth enabled)
|
|
49
|
+
3. Reject
|
|
50
|
+
|
|
51
|
+
If api_key is None and oauth is disabled, all requests pass.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
async def _check_auth(
|
|
55
|
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer_scheme),
|
|
56
|
+
) -> Optional[Dict[str, Any]]:
|
|
57
|
+
# No auth configured — open access
|
|
58
|
+
if api_key is None and not oauth_enabled:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
if credentials is None:
|
|
62
|
+
raise HTTPException(status_code=401, detail="Missing authentication")
|
|
63
|
+
|
|
64
|
+
token = credentials.credentials
|
|
65
|
+
|
|
66
|
+
# Check API key
|
|
67
|
+
if api_key is not None and secrets.compare_digest(token, api_key):
|
|
68
|
+
return {"auth_type": "api_key"}
|
|
69
|
+
|
|
70
|
+
# Check OAuth session token
|
|
71
|
+
if oauth_enabled and token in _oauth_tokens:
|
|
72
|
+
session = _oauth_tokens[token]
|
|
73
|
+
if session.get("expires_at", 0) > time.time():
|
|
74
|
+
return session
|
|
75
|
+
else:
|
|
76
|
+
del _oauth_tokens[token] # expired
|
|
77
|
+
|
|
78
|
+
raise HTTPException(status_code=401, detail="Invalid or missing API key")
|
|
79
|
+
|
|
80
|
+
return _check_auth
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def register_oauth_routes(
|
|
84
|
+
app: "FastAPI", provider: str, client_id: str, client_secret: str
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Add OAuth2 login/callback routes to the FastAPI app.
|
|
87
|
+
|
|
88
|
+
Supports: "github", "google"
|
|
89
|
+
"""
|
|
90
|
+
providers = {
|
|
91
|
+
"github": {
|
|
92
|
+
"authorize_url": "https://github.com/login/oauth/authorize",
|
|
93
|
+
"token_url": "https://github.com/login/oauth/access_token",
|
|
94
|
+
"user_url": "https://api.github.com/user",
|
|
95
|
+
"scope": "read:user",
|
|
96
|
+
},
|
|
97
|
+
"google": {
|
|
98
|
+
"authorize_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
|
99
|
+
"token_url": "https://oauth2.googleapis.com/token",
|
|
100
|
+
"user_url": "https://www.googleapis.com/oauth2/v2/userinfo",
|
|
101
|
+
"scope": "openid email profile",
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if provider not in providers:
|
|
106
|
+
raise ValueError(f"Unsupported OAuth provider: {provider}. Use: {list(providers.keys())}")
|
|
107
|
+
|
|
108
|
+
config = providers[provider]
|
|
109
|
+
|
|
110
|
+
@app.get("/auth/login")
|
|
111
|
+
def oauth_login(request: Request) -> Dict[str, Any]:
|
|
112
|
+
"""Redirect URL for OAuth2 login."""
|
|
113
|
+
callback_url = str(request.base_url) + "auth/callback"
|
|
114
|
+
state = secrets.token_hex(16)
|
|
115
|
+
url = (
|
|
116
|
+
f"{config['authorize_url']}?"
|
|
117
|
+
f"client_id={client_id}&"
|
|
118
|
+
f"redirect_uri={callback_url}&"
|
|
119
|
+
f"scope={config['scope']}&"
|
|
120
|
+
f"state={state}"
|
|
121
|
+
)
|
|
122
|
+
return {"login_url": url, "state": state}
|
|
123
|
+
|
|
124
|
+
@app.get("/auth/callback")
|
|
125
|
+
async def oauth_callback(code: str, state: str = "") -> Dict[str, Any]:
|
|
126
|
+
"""OAuth2 callback — exchange code for token, create session."""
|
|
127
|
+
try:
|
|
128
|
+
import httpx
|
|
129
|
+
except ImportError:
|
|
130
|
+
raise HTTPException(
|
|
131
|
+
status_code=500,
|
|
132
|
+
detail="httpx required for OAuth. pip install httpx",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Exchange code for access token
|
|
136
|
+
async with httpx.AsyncClient() as client:
|
|
137
|
+
token_resp = await client.post(
|
|
138
|
+
config["token_url"],
|
|
139
|
+
data={
|
|
140
|
+
"client_id": client_id,
|
|
141
|
+
"client_secret": client_secret,
|
|
142
|
+
"code": code,
|
|
143
|
+
},
|
|
144
|
+
headers={"Accept": "application/json"},
|
|
145
|
+
)
|
|
146
|
+
if token_resp.status_code != 200:
|
|
147
|
+
raise HTTPException(status_code=401, detail="OAuth token exchange failed")
|
|
148
|
+
token_data = token_resp.json()
|
|
149
|
+
access_token = token_data.get("access_token")
|
|
150
|
+
if not access_token:
|
|
151
|
+
raise HTTPException(status_code=401, detail="No access token in response")
|
|
152
|
+
|
|
153
|
+
# Get user info
|
|
154
|
+
user_resp = await client.get(
|
|
155
|
+
config["user_url"],
|
|
156
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
157
|
+
)
|
|
158
|
+
if user_resp.status_code != 200:
|
|
159
|
+
raise HTTPException(status_code=401, detail="Failed to fetch user info")
|
|
160
|
+
user_info = user_resp.json()
|
|
161
|
+
|
|
162
|
+
# Create a session token
|
|
163
|
+
session_token = secrets.token_hex(32)
|
|
164
|
+
_oauth_tokens[session_token] = {
|
|
165
|
+
"auth_type": "oauth",
|
|
166
|
+
"provider": provider,
|
|
167
|
+
"user": user_info.get("login") or user_info.get("email", "unknown"),
|
|
168
|
+
"user_info": user_info,
|
|
169
|
+
"expires_at": time.time() + 86400, # 24 hours
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
"token": session_token,
|
|
174
|
+
"user": _oauth_tokens[session_token]["user"],
|
|
175
|
+
"expires_in": 86400,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
@app.get("/auth/me")
|
|
179
|
+
async def auth_me(
|
|
180
|
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer_scheme),
|
|
181
|
+
) -> Dict[str, Any]:
|
|
182
|
+
"""Get info about the current authenticated user."""
|
|
183
|
+
if credentials is None:
|
|
184
|
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
185
|
+
session = _oauth_tokens.get(credentials.credentials)
|
|
186
|
+
if session and session.get("expires_at", 0) > time.time():
|
|
187
|
+
return {"user": session["user"], "provider": session.get("provider")}
|
|
188
|
+
raise HTTPException(status_code=401, detail="Invalid or expired token")
|