ghemud-agentkit 0.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.
- ghemud_agentkit/__init__.py +156 -0
- ghemud_agentkit/__main__.py +6 -0
- ghemud_agentkit/_version.py +12 -0
- ghemud_agentkit/approvals.py +498 -0
- ghemud_agentkit/audit.py +329 -0
- ghemud_agentkit/budgets.py +196 -0
- ghemud_agentkit/capabilities.py +96 -0
- ghemud_agentkit/cli/__init__.py +0 -0
- ghemud_agentkit/cli/main.py +392 -0
- ghemud_agentkit/concurrency.py +221 -0
- ghemud_agentkit/errors.py +218 -0
- ghemud_agentkit/events.py +275 -0
- ghemud_agentkit/interop/__init__.py +10 -0
- ghemud_agentkit/interop/mcp.py +187 -0
- ghemud_agentkit/lifecycle.py +242 -0
- ghemud_agentkit/loop.py +456 -0
- ghemud_agentkit/middleware.py +127 -0
- ghemud_agentkit/models/__init__.py +37 -0
- ghemud_agentkit/models/base.py +201 -0
- ghemud_agentkit/models/fake.py +172 -0
- ghemud_agentkit/models/openai_adapter.py +160 -0
- ghemud_agentkit/permissions.py +371 -0
- ghemud_agentkit/py.typed +1 -0
- ghemud_agentkit/registry.py +300 -0
- ghemud_agentkit/resilience.py +249 -0
- ghemud_agentkit/results.py +315 -0
- ghemud_agentkit/runtime.py +1158 -0
- ghemud_agentkit/sandbox.py +250 -0
- ghemud_agentkit/schema/__init__.py +39 -0
- ghemud_agentkit/schema/generation.py +541 -0
- ghemud_agentkit/schema/redaction.py +170 -0
- ghemud_agentkit/schema/validation.py +446 -0
- ghemud_agentkit/security.py +151 -0
- ghemud_agentkit/state.py +140 -0
- ghemud_agentkit/tools/__init__.py +30 -0
- ghemud_agentkit/tools/base.py +400 -0
- ghemud_agentkit/tools/builtin.py +263 -0
- ghemud_agentkit/tools/decorator.py +357 -0
- ghemud_agentkit/tracing.py +206 -0
- ghemud_agentkit-0.1.0.dist-info/METADATA +158 -0
- ghemud_agentkit-0.1.0.dist-info/RECORD +45 -0
- ghemud_agentkit-0.1.0.dist-info/WHEEL +4 -0
- ghemud_agentkit-0.1.0.dist-info/entry_points.txt +2 -0
- ghemud_agentkit-0.1.0.dist-info/licenses/LICENSE +15 -0
- ghemud_agentkit-0.1.0.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Ghemud AgentKit: a secure, provider-agnostic runtime for AI agent tools.
|
|
2
|
+
|
|
3
|
+
Created and maintained by Yashraj Sachin Ghemud.
|
|
4
|
+
|
|
5
|
+
Quick start::
|
|
6
|
+
|
|
7
|
+
from ghemud_agentkit import ToolRegistry, ToolRuntime, tool
|
|
8
|
+
|
|
9
|
+
@tool(description="Add two integers")
|
|
10
|
+
def add(a: int, b: int) -> int:
|
|
11
|
+
return a + b
|
|
12
|
+
|
|
13
|
+
registry = ToolRegistry()
|
|
14
|
+
registry.register(add)
|
|
15
|
+
|
|
16
|
+
runtime = ToolRuntime(registry)
|
|
17
|
+
result = await runtime.run("add", {"a": 2, "b": 3})
|
|
18
|
+
assert result.content == 5
|
|
19
|
+
|
|
20
|
+
The public API is intentionally small. Everything else (policies, approvals,
|
|
21
|
+
audit, models, loops) is importable from its submodule but only surfaced
|
|
22
|
+
here once it is stable.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from ghemud_agentkit._version import __version__, __version_info__
|
|
26
|
+
from ghemud_agentkit.approvals import (
|
|
27
|
+
ApprovalDecision,
|
|
28
|
+
ApprovalManager,
|
|
29
|
+
ApprovalProvider,
|
|
30
|
+
ApprovalRequest,
|
|
31
|
+
AutoDenyProvider,
|
|
32
|
+
CallbackProvider,
|
|
33
|
+
InteractiveConsoleProvider,
|
|
34
|
+
StaticProvider,
|
|
35
|
+
)
|
|
36
|
+
from ghemud_agentkit.audit import AuditSink, InMemoryAuditSink, JsonlFileAuditSink
|
|
37
|
+
from ghemud_agentkit.budgets import Budget, BudgetSet
|
|
38
|
+
from ghemud_agentkit.capabilities import BUILTIN_CAPABILITIES, Capability
|
|
39
|
+
from ghemud_agentkit.events import EventBus, EventType, InMemoryEventCollector
|
|
40
|
+
from ghemud_agentkit.lifecycle import InvocationState
|
|
41
|
+
from ghemud_agentkit.loop import AgentLoop, LoopConfig, LoopResult, StopReason
|
|
42
|
+
from ghemud_agentkit.middleware import InvocationGate, Middleware, MiddlewareChain
|
|
43
|
+
from ghemud_agentkit.models.base import (
|
|
44
|
+
ModelClient,
|
|
45
|
+
ModelMessage,
|
|
46
|
+
ModelRequest,
|
|
47
|
+
ModelResponse,
|
|
48
|
+
ModelToolCall,
|
|
49
|
+
)
|
|
50
|
+
from ghemud_agentkit.models.fake import ScriptedModel
|
|
51
|
+
from ghemud_agentkit.permissions import (
|
|
52
|
+
ChainedPolicy,
|
|
53
|
+
DefaultPolicy,
|
|
54
|
+
Effect,
|
|
55
|
+
PermissionPolicy,
|
|
56
|
+
PermissionRequest,
|
|
57
|
+
PolicyDecision,
|
|
58
|
+
PolicyRule,
|
|
59
|
+
RuleBasedPolicy,
|
|
60
|
+
policy_from_dict,
|
|
61
|
+
)
|
|
62
|
+
from ghemud_agentkit.registry import CompositeToolRegistry, RegistrySnapshot, ToolRegistry
|
|
63
|
+
from ghemud_agentkit.resilience import RetryPolicy
|
|
64
|
+
from ghemud_agentkit.results import FailureKind, ResultStatus, ToolResult
|
|
65
|
+
from ghemud_agentkit.runtime import InvocationMetadata, RuntimeConfig, ToolRuntime
|
|
66
|
+
from ghemud_agentkit.sandbox import (
|
|
67
|
+
SANDBOX_TRUST_BOUNDARY,
|
|
68
|
+
SandboxExecutor,
|
|
69
|
+
SandboxRequest,
|
|
70
|
+
SandboxResult,
|
|
71
|
+
SubprocessSandboxExecutor,
|
|
72
|
+
)
|
|
73
|
+
from ghemud_agentkit.state import InMemoryStateStore, StateStore
|
|
74
|
+
from ghemud_agentkit.tools import (
|
|
75
|
+
Example,
|
|
76
|
+
RateLimit,
|
|
77
|
+
RiskLevel,
|
|
78
|
+
SideEffects,
|
|
79
|
+
Tool,
|
|
80
|
+
ToolContext,
|
|
81
|
+
ToolSpec,
|
|
82
|
+
tool,
|
|
83
|
+
)
|
|
84
|
+
from ghemud_agentkit.tracing import TraceRecorder
|
|
85
|
+
|
|
86
|
+
__all__ = [
|
|
87
|
+
"BUILTIN_CAPABILITIES",
|
|
88
|
+
"SANDBOX_TRUST_BOUNDARY",
|
|
89
|
+
"AgentLoop",
|
|
90
|
+
"ApprovalDecision",
|
|
91
|
+
"ApprovalManager",
|
|
92
|
+
"ApprovalProvider",
|
|
93
|
+
"ApprovalRequest",
|
|
94
|
+
"AuditSink",
|
|
95
|
+
"AutoDenyProvider",
|
|
96
|
+
"Budget",
|
|
97
|
+
"BudgetSet",
|
|
98
|
+
"CallbackProvider",
|
|
99
|
+
"Capability",
|
|
100
|
+
"ChainedPolicy",
|
|
101
|
+
"CompositeToolRegistry",
|
|
102
|
+
"DefaultPolicy",
|
|
103
|
+
"Effect",
|
|
104
|
+
"EventBus",
|
|
105
|
+
"EventType",
|
|
106
|
+
"Example",
|
|
107
|
+
"FailureKind",
|
|
108
|
+
"InMemoryAuditSink",
|
|
109
|
+
"InMemoryEventCollector",
|
|
110
|
+
"InMemoryStateStore",
|
|
111
|
+
"InteractiveConsoleProvider",
|
|
112
|
+
"InvocationGate",
|
|
113
|
+
"InvocationMetadata",
|
|
114
|
+
"InvocationState",
|
|
115
|
+
"JsonlFileAuditSink",
|
|
116
|
+
"LoopConfig",
|
|
117
|
+
"LoopResult",
|
|
118
|
+
"Middleware",
|
|
119
|
+
"MiddlewareChain",
|
|
120
|
+
"ModelClient",
|
|
121
|
+
"ModelMessage",
|
|
122
|
+
"ModelRequest",
|
|
123
|
+
"ModelResponse",
|
|
124
|
+
"ModelToolCall",
|
|
125
|
+
"PermissionPolicy",
|
|
126
|
+
"PermissionRequest",
|
|
127
|
+
"PolicyDecision",
|
|
128
|
+
"PolicyRule",
|
|
129
|
+
"RateLimit",
|
|
130
|
+
"RegistrySnapshot",
|
|
131
|
+
"ResultStatus",
|
|
132
|
+
"RetryPolicy",
|
|
133
|
+
"RiskLevel",
|
|
134
|
+
"RuleBasedPolicy",
|
|
135
|
+
"RuntimeConfig",
|
|
136
|
+
"SandboxExecutor",
|
|
137
|
+
"SandboxRequest",
|
|
138
|
+
"SandboxResult",
|
|
139
|
+
"ScriptedModel",
|
|
140
|
+
"SideEffects",
|
|
141
|
+
"StateStore",
|
|
142
|
+
"StaticProvider",
|
|
143
|
+
"StopReason",
|
|
144
|
+
"SubprocessSandboxExecutor",
|
|
145
|
+
"Tool",
|
|
146
|
+
"ToolContext",
|
|
147
|
+
"ToolRegistry",
|
|
148
|
+
"ToolResult",
|
|
149
|
+
"ToolRuntime",
|
|
150
|
+
"ToolSpec",
|
|
151
|
+
"TraceRecorder",
|
|
152
|
+
"__version__",
|
|
153
|
+
"__version_info__",
|
|
154
|
+
"policy_from_dict",
|
|
155
|
+
"tool",
|
|
156
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Single authoritative source for the Ghemud AgentKit package version.
|
|
2
|
+
|
|
3
|
+
Every other module (and the build backend via ``[tool.hatch.version]``) reads
|
|
4
|
+
the version from here so there is exactly one place to bump it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
#: Public compatibility surface, following semver-2.0.0.
|
|
12
|
+
__version_info__ = tuple(int(part) for part in __version__.split(".")[:3])
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"""Human-in-the-loop approval for risky tool invocations.
|
|
2
|
+
|
|
3
|
+
Flow
|
|
4
|
+
----
|
|
5
|
+
1. Policy evaluates to ``approval_required``.
|
|
6
|
+
2. Runtime creates an :class:`ApprovalRequest` (tool, redacted arguments,
|
|
7
|
+
capabilities, reason, invocation metadata) with a *request TTL*.
|
|
8
|
+
3. An :class:`ApprovalProvider` obtains a human decision (or an automated
|
|
9
|
+
one — providers are just callables with opinions).
|
|
10
|
+
4. The resulting :class:`ApprovalDecision` carries its *decision TTL*:
|
|
11
|
+
approvals are perishable. A decision that sat in a queue longer than its
|
|
12
|
+
TTL is treated as expired and the invocation is denied — approvals must
|
|
13
|
+
never silently persist forever (engineering requirement 12).
|
|
14
|
+
5. Non-interactive environments use :class:`AutoDenyProvider` by default:
|
|
15
|
+
if nobody can answer, the answer is no, unless the operator explicitly
|
|
16
|
+
installs a provider that says otherwise.
|
|
17
|
+
|
|
18
|
+
Every step emits an event (``approval.requested/granted/denied/expired``) so
|
|
19
|
+
the audit trail answers "who approved what, when, and for which arguments".
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import fnmatch
|
|
26
|
+
import time
|
|
27
|
+
import uuid
|
|
28
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from typing import Any, Protocol
|
|
31
|
+
|
|
32
|
+
from ghemud_agentkit.errors import ApprovalDeniedError, ApprovalExpiredError, ApprovalTimeoutError
|
|
33
|
+
from ghemud_agentkit.events import EventBus, EventType
|
|
34
|
+
from ghemud_agentkit.permissions import PolicyDecision
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"ApprovalDecision",
|
|
38
|
+
"ApprovalManager",
|
|
39
|
+
"ApprovalProvider",
|
|
40
|
+
"ApprovalRequest",
|
|
41
|
+
"ApprovalStatus",
|
|
42
|
+
"AutoDenyProvider",
|
|
43
|
+
"CallbackProvider",
|
|
44
|
+
"InteractiveConsoleProvider",
|
|
45
|
+
"StaticProvider",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class ApprovalRequest:
|
|
51
|
+
"""A request for human approval of one would-be invocation."""
|
|
52
|
+
|
|
53
|
+
request_id: str
|
|
54
|
+
tool_name: str
|
|
55
|
+
arguments: Mapping[str, Any] # already redacted
|
|
56
|
+
capabilities: frozenset[str]
|
|
57
|
+
reason: str
|
|
58
|
+
risk_level: int
|
|
59
|
+
invocation_id: str | None
|
|
60
|
+
actor_id: str | None
|
|
61
|
+
agent_id: str | None
|
|
62
|
+
created_at: float = field(default_factory=time.time)
|
|
63
|
+
request_ttl: float = 300.0 # how long the *request* may stay pending
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def expires_at(self) -> float:
|
|
67
|
+
return self.created_at + self.request_ttl
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def expired(self) -> bool:
|
|
71
|
+
return time.time() > self.expires_at
|
|
72
|
+
|
|
73
|
+
def as_dict(self) -> dict[str, Any]:
|
|
74
|
+
return {
|
|
75
|
+
"request_id": self.request_id,
|
|
76
|
+
"tool_name": self.tool_name,
|
|
77
|
+
"arguments": dict(self.arguments),
|
|
78
|
+
"capabilities": sorted(self.capabilities),
|
|
79
|
+
"reason": self.reason,
|
|
80
|
+
"risk_level": self.risk_level,
|
|
81
|
+
"invocation_id": self.invocation_id,
|
|
82
|
+
"actor_id": self.actor_id,
|
|
83
|
+
"agent_id": self.agent_id,
|
|
84
|
+
"created_at": self.created_at,
|
|
85
|
+
"request_ttl": self.request_ttl,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ApprovalStatus:
|
|
90
|
+
"""Lifecycle states of an approval request."""
|
|
91
|
+
|
|
92
|
+
PENDING = "pending"
|
|
93
|
+
APPROVED = "approved"
|
|
94
|
+
DENIED = "denied"
|
|
95
|
+
EXPIRED = "expired"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class ApprovalDecision:
|
|
100
|
+
"""A human (or automated) decision about an approval request."""
|
|
101
|
+
|
|
102
|
+
request_id: str
|
|
103
|
+
approved: bool
|
|
104
|
+
decided_by: str
|
|
105
|
+
decided_at: float = field(default_factory=time.time)
|
|
106
|
+
reason: str = ""
|
|
107
|
+
decision_ttl: float = 60.0 # how long the *decision* stays valid
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def expires_at(self) -> float:
|
|
111
|
+
return self.decided_at + self.decision_ttl
|
|
112
|
+
|
|
113
|
+
def expired(self, *, now: float | None = None) -> bool:
|
|
114
|
+
current = now if now is not None else time.time()
|
|
115
|
+
return current > self.expires_at
|
|
116
|
+
|
|
117
|
+
def as_dict(self) -> dict[str, Any]:
|
|
118
|
+
return {
|
|
119
|
+
"request_id": self.request_id,
|
|
120
|
+
"approved": self.approved,
|
|
121
|
+
"decided_by": self.decided_by,
|
|
122
|
+
"decided_at": self.decided_at,
|
|
123
|
+
"reason": self.reason,
|
|
124
|
+
"decision_ttl": self.decision_ttl,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class ApprovalProvider(Protocol):
|
|
129
|
+
"""Anything that can produce approval decisions."""
|
|
130
|
+
|
|
131
|
+
async def decide(self, request: ApprovalRequest) -> ApprovalDecision:
|
|
132
|
+
"""Return a decision for ``request``. Must not raise for policy
|
|
133
|
+
reasons — return ``approved=False`` instead. Infrastructure errors may
|
|
134
|
+
propagate; the runtime converts them into denials with the failure
|
|
135
|
+
reason recorded."""
|
|
136
|
+
...
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class AutoDenyProvider:
|
|
140
|
+
"""Denies everything — the safe default for non-interactive runtimes.
|
|
141
|
+
|
|
142
|
+
An unattended process has no human to ask; fabricating an "approved"
|
|
143
|
+
would defeat the entire point of approval-required. Operators who want
|
|
144
|
+
unattended approval can install :class:`StaticProvider` *explicitly*,
|
|
145
|
+
which is the documented tradeoff (engineering requirement 20).
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def __init__(self, *, reason: str = "non-interactive runtime: auto-deny") -> None:
|
|
149
|
+
self.reason = reason
|
|
150
|
+
|
|
151
|
+
async def decide(self, request: ApprovalRequest) -> ApprovalDecision:
|
|
152
|
+
return ApprovalDecision(
|
|
153
|
+
request_id=request.request_id,
|
|
154
|
+
approved=False,
|
|
155
|
+
decided_by="auto-deny",
|
|
156
|
+
reason=self.reason,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class StaticProvider:
|
|
161
|
+
"""Approves (or denies) everything unconditionally.
|
|
162
|
+
|
|
163
|
+
Exists for tests and for explicitly-configured unattended runs. The
|
|
164
|
+
constructor takes the decision *once* so the audit trail records who
|
|
165
|
+
pre-authorized the blanket decision.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(self, *, approved: bool, decided_by: str = "static") -> None:
|
|
169
|
+
self.approved = approved
|
|
170
|
+
self.decided_by = decided_by
|
|
171
|
+
|
|
172
|
+
async def decide(self, request: ApprovalRequest) -> ApprovalDecision:
|
|
173
|
+
return ApprovalDecision(
|
|
174
|
+
request_id=request.request_id,
|
|
175
|
+
approved=self.approved,
|
|
176
|
+
decided_by=self.decided_by,
|
|
177
|
+
reason="static provider configuration",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class CallbackProvider:
|
|
182
|
+
"""Adapter turning any callable into a provider.
|
|
183
|
+
|
|
184
|
+
The callable receives the request and returns a boolean (or an
|
|
185
|
+
:class:`ApprovalDecision`). Useful for wiring GUI callbacks, queue
|
|
186
|
+
consumers, or test doubles without defining a class.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
def __init__(
|
|
190
|
+
self,
|
|
191
|
+
callback: Callable[[ApprovalRequest], bool | ApprovalDecision | Awaitable[Any]],
|
|
192
|
+
*,
|
|
193
|
+
decided_by: str = "callback",
|
|
194
|
+
) -> None:
|
|
195
|
+
self._callback = callback
|
|
196
|
+
self._decided_by = decided_by
|
|
197
|
+
|
|
198
|
+
async def decide(self, request: ApprovalRequest) -> ApprovalDecision:
|
|
199
|
+
outcome = self._callback(request)
|
|
200
|
+
if hasattr(outcome, "__await__"):
|
|
201
|
+
outcome = await outcome
|
|
202
|
+
if isinstance(outcome, ApprovalDecision):
|
|
203
|
+
return outcome
|
|
204
|
+
return ApprovalDecision(
|
|
205
|
+
request_id=request.request_id,
|
|
206
|
+
approved=bool(outcome),
|
|
207
|
+
decided_by=self._decided_by,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class InteractiveConsoleProvider:
|
|
212
|
+
"""Prompts a human on stdin — for CLI/debugging use only.
|
|
213
|
+
|
|
214
|
+
If stdin is not a TTY the provider auto-denies rather than hanging a
|
|
215
|
+
headless process forever (same posture as AutoDenyProvider).
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
def __init__(self, *, decision_ttl: float = 60.0) -> None:
|
|
219
|
+
self.decision_ttl = decision_ttl
|
|
220
|
+
|
|
221
|
+
async def decide(self, request: ApprovalRequest) -> ApprovalDecision:
|
|
222
|
+
import sys
|
|
223
|
+
|
|
224
|
+
if not sys.stdin.isatty(): # pragma: no cover - environment-dependent
|
|
225
|
+
return ApprovalDecision(
|
|
226
|
+
request_id=request.request_id,
|
|
227
|
+
approved=False,
|
|
228
|
+
decided_by="console",
|
|
229
|
+
reason="stdin is not interactive; auto-denied",
|
|
230
|
+
)
|
|
231
|
+
loop = asyncio.get_running_loop()
|
|
232
|
+
prompt = (
|
|
233
|
+
f"\n[approval] {request.tool_name} (risk={request.risk_level})\n"
|
|
234
|
+
f" capabilities: {sorted(request.capabilities)}\n"
|
|
235
|
+
f" arguments: {dict(request.arguments)}\n"
|
|
236
|
+
f" reason: {request.reason}\n"
|
|
237
|
+
f"Approve? [y/N] "
|
|
238
|
+
)
|
|
239
|
+
answer = await loop.run_in_executor(None, input, prompt)
|
|
240
|
+
approved = answer.strip().lower() in {"y", "yes"}
|
|
241
|
+
return ApprovalDecision(
|
|
242
|
+
request_id=request.request_id,
|
|
243
|
+
approved=approved,
|
|
244
|
+
decided_by=f"console:{'approved' if approved else 'denied'}",
|
|
245
|
+
decision_ttl=self.decision_ttl,
|
|
246
|
+
reason="interactive console decision",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class ApprovalManager:
|
|
251
|
+
"""Creates approval requests, routes them to a provider, tracks state.
|
|
252
|
+
|
|
253
|
+
Thread-safety: the manager is asyncio-native (single event loop). The
|
|
254
|
+
registry of decisions is guarded by a plain dict + event-loop affinity;
|
|
255
|
+
cross-loop use is not supported and documented as such.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
def __init__(
|
|
259
|
+
self,
|
|
260
|
+
provider: ApprovalProvider | None = None,
|
|
261
|
+
*,
|
|
262
|
+
events: EventBus | None = None,
|
|
263
|
+
default_request_ttl: float = 300.0,
|
|
264
|
+
default_decision_ttl: float = 60.0,
|
|
265
|
+
) -> None:
|
|
266
|
+
self._provider = provider or AutoDenyProvider()
|
|
267
|
+
self._events = events
|
|
268
|
+
self._default_request_ttl = default_request_ttl
|
|
269
|
+
self._default_decision_ttl = default_decision_ttl
|
|
270
|
+
self._requests: dict[str, ApprovalRequest] = {}
|
|
271
|
+
self._decisions: dict[str, ApprovalDecision] = {}
|
|
272
|
+
self._waiters: dict[str, list[asyncio.Future[ApprovalDecision]]] = {}
|
|
273
|
+
|
|
274
|
+
def attach_events(self, events: EventBus) -> None:
|
|
275
|
+
"""Route approval telemetry to an event bus (idempotent)."""
|
|
276
|
+
self._events = events
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def provider(self) -> ApprovalProvider:
|
|
280
|
+
return self._provider
|
|
281
|
+
|
|
282
|
+
def set_provider(self, provider: ApprovalProvider) -> None:
|
|
283
|
+
self._provider = provider
|
|
284
|
+
|
|
285
|
+
def create_request(
|
|
286
|
+
self,
|
|
287
|
+
*,
|
|
288
|
+
tool_name: str,
|
|
289
|
+
arguments: Mapping[str, Any],
|
|
290
|
+
capabilities: frozenset[str],
|
|
291
|
+
decision: PolicyDecision,
|
|
292
|
+
risk_level: int,
|
|
293
|
+
invocation_id: str | None = None,
|
|
294
|
+
actor_id: str | None = None,
|
|
295
|
+
agent_id: str | None = None,
|
|
296
|
+
request_ttl: float | None = None,
|
|
297
|
+
) -> ApprovalRequest:
|
|
298
|
+
"""Register a pending approval request (does not contact the provider)."""
|
|
299
|
+
request = ApprovalRequest(
|
|
300
|
+
request_id=uuid.uuid4().hex,
|
|
301
|
+
tool_name=tool_name,
|
|
302
|
+
arguments=dict(arguments),
|
|
303
|
+
capabilities=frozenset(capabilities),
|
|
304
|
+
reason=decision.reason,
|
|
305
|
+
risk_level=risk_level,
|
|
306
|
+
invocation_id=invocation_id,
|
|
307
|
+
actor_id=actor_id,
|
|
308
|
+
agent_id=agent_id,
|
|
309
|
+
request_ttl=request_ttl if request_ttl is not None else self._default_request_ttl,
|
|
310
|
+
)
|
|
311
|
+
self._requests[request.request_id] = request
|
|
312
|
+
if self._events is not None:
|
|
313
|
+
self._events.emit(
|
|
314
|
+
EventType.APPROVAL_REQUESTED,
|
|
315
|
+
invocation_id=invocation_id,
|
|
316
|
+
data={"request_id": request.request_id, "tool": tool_name},
|
|
317
|
+
)
|
|
318
|
+
return request
|
|
319
|
+
|
|
320
|
+
async def resolve(self, request: ApprovalRequest) -> ApprovalDecision:
|
|
321
|
+
"""Ask the provider for a decision and record it.
|
|
322
|
+
|
|
323
|
+
Enforces the request TTL: if the provider stalls longer than the
|
|
324
|
+
request's lifetime, the outcome is an expired decision, not an
|
|
325
|
+
eternal wait.
|
|
326
|
+
"""
|
|
327
|
+
provider_task = asyncio.create_task(self._provider.decide(request))
|
|
328
|
+
try:
|
|
329
|
+
decision = await asyncio.wait_for(
|
|
330
|
+
provider_task, timeout=max(0.001, request.expires_at - time.time())
|
|
331
|
+
)
|
|
332
|
+
except TimeoutError:
|
|
333
|
+
provider_task.cancel()
|
|
334
|
+
decision = ApprovalDecision(
|
|
335
|
+
request_id=request.request_id,
|
|
336
|
+
approved=False,
|
|
337
|
+
decided_by="timeout",
|
|
338
|
+
reason="approval provider did not answer within the request TTL",
|
|
339
|
+
)
|
|
340
|
+
except asyncio.CancelledError:
|
|
341
|
+
provider_task.cancel()
|
|
342
|
+
raise
|
|
343
|
+
except Exception as exc:
|
|
344
|
+
decision = ApprovalDecision(
|
|
345
|
+
request_id=request.request_id,
|
|
346
|
+
approved=False,
|
|
347
|
+
decided_by="error",
|
|
348
|
+
reason=f"approval provider failed: {exc.__class__.__name__}",
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if decision.approved and request.expired:
|
|
352
|
+
decision = ApprovalDecision(
|
|
353
|
+
request_id=request.request_id,
|
|
354
|
+
approved=False,
|
|
355
|
+
decided_by="expired",
|
|
356
|
+
reason="decision arrived after the request expired",
|
|
357
|
+
)
|
|
358
|
+
self._record(request, decision)
|
|
359
|
+
return decision
|
|
360
|
+
|
|
361
|
+
def _record(self, request: ApprovalRequest, decision: ApprovalDecision) -> None:
|
|
362
|
+
self._decisions[request.request_id] = decision
|
|
363
|
+
for waiter in self._waiters.pop(request.request_id, []):
|
|
364
|
+
if not waiter.done():
|
|
365
|
+
waiter.set_result(decision)
|
|
366
|
+
if self._events is not None:
|
|
367
|
+
event_type = (
|
|
368
|
+
EventType.APPROVAL_GRANTED if decision.approved else EventType.APPROVAL_DENIED
|
|
369
|
+
)
|
|
370
|
+
self._events.emit(
|
|
371
|
+
event_type,
|
|
372
|
+
invocation_id=request.invocation_id,
|
|
373
|
+
data={
|
|
374
|
+
"request_id": request.request_id,
|
|
375
|
+
"tool": request.tool_name,
|
|
376
|
+
"decided_by": decision.decided_by,
|
|
377
|
+
},
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
def get_decision(self, request_id: str) -> ApprovalDecision | None:
|
|
381
|
+
return self._decisions.get(request_id)
|
|
382
|
+
|
|
383
|
+
def get_request(self, request_id: str) -> ApprovalRequest | None:
|
|
384
|
+
return self._requests.get(request_id)
|
|
385
|
+
|
|
386
|
+
def validate_decision(self, decision: ApprovalDecision) -> None:
|
|
387
|
+
"""Raise if a decision cannot be used to dispatch work right now.
|
|
388
|
+
|
|
389
|
+
This is the decision-TTL gate: the runtime calls it immediately
|
|
390
|
+
before transitioning to ``queued``. An approval older than its TTL
|
|
391
|
+
raises :class:`ApprovalExpiredError`, forcing re-approval.
|
|
392
|
+
"""
|
|
393
|
+
if decision.expired():
|
|
394
|
+
if self._events is not None:
|
|
395
|
+
self._events.emit(
|
|
396
|
+
EventType.APPROVAL_EXPIRED,
|
|
397
|
+
data={
|
|
398
|
+
"request_id": decision.request_id,
|
|
399
|
+
"decided_at": decision.decided_at,
|
|
400
|
+
},
|
|
401
|
+
)
|
|
402
|
+
raise ApprovalExpiredError(
|
|
403
|
+
f"approval for request {decision.request_id} expired at "
|
|
404
|
+
f"{decision.expires_at:.3f}; re-approval is required"
|
|
405
|
+
)
|
|
406
|
+
if not decision.approved:
|
|
407
|
+
raise ApprovalDeniedError(
|
|
408
|
+
f"approval request {decision.request_id} was denied by "
|
|
409
|
+
f"{decision.decided_by}: {decision.reason}",
|
|
410
|
+
capabilities=[],
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
async def wait_for_decision(
|
|
414
|
+
self, request: ApprovalRequest, *, timeout: float | None = None
|
|
415
|
+
) -> ApprovalDecision:
|
|
416
|
+
"""Async wait for a decision recorded via :meth:`_record`.
|
|
417
|
+
|
|
418
|
+
Used by interactive approval UIs that call ``approve()``/``deny()``
|
|
419
|
+
from other tasks.
|
|
420
|
+
"""
|
|
421
|
+
existing = self._decisions.get(request.request_id)
|
|
422
|
+
if existing is not None:
|
|
423
|
+
return existing
|
|
424
|
+
waiter: asyncio.Future[ApprovalDecision] = asyncio.get_running_loop().create_future()
|
|
425
|
+
self._waiters.setdefault(request.request_id, []).append(waiter)
|
|
426
|
+
try:
|
|
427
|
+
return await asyncio.wait_for(waiter, timeout=timeout)
|
|
428
|
+
except TimeoutError as exc:
|
|
429
|
+
raise ApprovalTimeoutError(
|
|
430
|
+
f"no approval decision within {timeout}s for request {request.request_id}"
|
|
431
|
+
) from exc
|
|
432
|
+
|
|
433
|
+
# -- programmatic decision entry points (for tests / external UIs) ------
|
|
434
|
+
|
|
435
|
+
def approve(
|
|
436
|
+
self, request_id: str, *, decided_by: str, decision_ttl: float | None = None
|
|
437
|
+
) -> None:
|
|
438
|
+
"""Record an approval programmatically (idempotent per request)."""
|
|
439
|
+
self._apply_decision(
|
|
440
|
+
request_id, approved=True, decided_by=decided_by, decision_ttl=decision_ttl
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
def deny(self, request_id: str, *, decided_by: str) -> None:
|
|
444
|
+
"""Record a denial programmatically."""
|
|
445
|
+
self._apply_decision(request_id, approved=False, decided_by=decided_by)
|
|
446
|
+
|
|
447
|
+
def _apply_decision(
|
|
448
|
+
self,
|
|
449
|
+
request_id: str,
|
|
450
|
+
*,
|
|
451
|
+
approved: bool,
|
|
452
|
+
decided_by: str,
|
|
453
|
+
decision_ttl: float | None = None,
|
|
454
|
+
) -> None:
|
|
455
|
+
request = self._requests.get(request_id)
|
|
456
|
+
if request is None:
|
|
457
|
+
raise KeyError(f"unknown approval request {request_id!r}")
|
|
458
|
+
if request_id in self._decisions:
|
|
459
|
+
raise ValueError(f"request {request_id!r} already has a decision")
|
|
460
|
+
if request.expired:
|
|
461
|
+
decision = ApprovalDecision(
|
|
462
|
+
request_id=request_id,
|
|
463
|
+
approved=False,
|
|
464
|
+
decided_by="expired",
|
|
465
|
+
reason="decision arrived after request expiry",
|
|
466
|
+
)
|
|
467
|
+
else:
|
|
468
|
+
decision = ApprovalDecision(
|
|
469
|
+
request_id=request_id,
|
|
470
|
+
approved=approved,
|
|
471
|
+
decided_by=decided_by,
|
|
472
|
+
decision_ttl=decision_ttl
|
|
473
|
+
if decision_ttl is not None
|
|
474
|
+
else self._default_decision_ttl,
|
|
475
|
+
)
|
|
476
|
+
self._record(request, decision)
|
|
477
|
+
|
|
478
|
+
# -- reporting -----------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
def pending(self, *, tool_pattern: str | None = None) -> list[ApprovalRequest]:
|
|
481
|
+
"""Requests still awaiting a decision (optionally filtered by tool)."""
|
|
482
|
+
return [
|
|
483
|
+
request
|
|
484
|
+
for request in self._requests.values()
|
|
485
|
+
if request.request_id not in self._decisions
|
|
486
|
+
and not request.expired
|
|
487
|
+
and (tool_pattern is None or fnmatch.fnmatch(request.tool_name, tool_pattern))
|
|
488
|
+
]
|
|
489
|
+
|
|
490
|
+
def stats(self) -> dict[str, int]:
|
|
491
|
+
approved = sum(1 for d in self._decisions.values() if d.approved)
|
|
492
|
+
denied = len(self._decisions) - approved
|
|
493
|
+
return {
|
|
494
|
+
"total_requests": len(self._requests),
|
|
495
|
+
"approved": approved,
|
|
496
|
+
"denied": denied,
|
|
497
|
+
"pending": len(self.pending()),
|
|
498
|
+
}
|