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.
Files changed (45) hide show
  1. ghemud_agentkit/__init__.py +156 -0
  2. ghemud_agentkit/__main__.py +6 -0
  3. ghemud_agentkit/_version.py +12 -0
  4. ghemud_agentkit/approvals.py +498 -0
  5. ghemud_agentkit/audit.py +329 -0
  6. ghemud_agentkit/budgets.py +196 -0
  7. ghemud_agentkit/capabilities.py +96 -0
  8. ghemud_agentkit/cli/__init__.py +0 -0
  9. ghemud_agentkit/cli/main.py +392 -0
  10. ghemud_agentkit/concurrency.py +221 -0
  11. ghemud_agentkit/errors.py +218 -0
  12. ghemud_agentkit/events.py +275 -0
  13. ghemud_agentkit/interop/__init__.py +10 -0
  14. ghemud_agentkit/interop/mcp.py +187 -0
  15. ghemud_agentkit/lifecycle.py +242 -0
  16. ghemud_agentkit/loop.py +456 -0
  17. ghemud_agentkit/middleware.py +127 -0
  18. ghemud_agentkit/models/__init__.py +37 -0
  19. ghemud_agentkit/models/base.py +201 -0
  20. ghemud_agentkit/models/fake.py +172 -0
  21. ghemud_agentkit/models/openai_adapter.py +160 -0
  22. ghemud_agentkit/permissions.py +371 -0
  23. ghemud_agentkit/py.typed +1 -0
  24. ghemud_agentkit/registry.py +300 -0
  25. ghemud_agentkit/resilience.py +249 -0
  26. ghemud_agentkit/results.py +315 -0
  27. ghemud_agentkit/runtime.py +1158 -0
  28. ghemud_agentkit/sandbox.py +250 -0
  29. ghemud_agentkit/schema/__init__.py +39 -0
  30. ghemud_agentkit/schema/generation.py +541 -0
  31. ghemud_agentkit/schema/redaction.py +170 -0
  32. ghemud_agentkit/schema/validation.py +446 -0
  33. ghemud_agentkit/security.py +151 -0
  34. ghemud_agentkit/state.py +140 -0
  35. ghemud_agentkit/tools/__init__.py +30 -0
  36. ghemud_agentkit/tools/base.py +400 -0
  37. ghemud_agentkit/tools/builtin.py +263 -0
  38. ghemud_agentkit/tools/decorator.py +357 -0
  39. ghemud_agentkit/tracing.py +206 -0
  40. ghemud_agentkit-0.1.0.dist-info/METADATA +158 -0
  41. ghemud_agentkit-0.1.0.dist-info/RECORD +45 -0
  42. ghemud_agentkit-0.1.0.dist-info/WHEEL +4 -0
  43. ghemud_agentkit-0.1.0.dist-info/entry_points.txt +2 -0
  44. ghemud_agentkit-0.1.0.dist-info/licenses/LICENSE +15 -0
  45. 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,6 @@
1
+ """``python -m ghemud_agentkit`` entry point — equivalent to the ``ghemud_agentkit`` CLI."""
2
+
3
+ from ghemud_agentkit.cli.main import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -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
+ }