aynigma 0.3.1__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.
aynigma/__init__.py ADDED
@@ -0,0 +1,61 @@
1
+ from .client import (
2
+ APIError,
3
+ AsyncAynigma,
4
+ AuthenticationError,
5
+ Aynigma,
6
+ AynigmaError,
7
+ BlockedError,
8
+ QuotaExceededError,
9
+ ValidationResult,
10
+ aguard_openai_chat,
11
+ aguard_openai_chat_v2,
12
+ guard_openai_chat,
13
+ guard_openai_chat_v2,
14
+ )
15
+ from .agent_errors import MCPError, TokenError
16
+ from .agent_tokens import AgentTokenProvider, DelegatedToken
17
+ from .agents import AgentAuthorizer, ApprovalRequired, RunKilled, ClaudeAgentApprovalBridge, ExecutionReceipt, RemoteTool, ToolDenied, ToolExecutor, OpenAIAgentsApprovalBridge, SemanticKernelApprovalBridge, acting_for_user, as_langchain_tool, build_langgraph_approval_workflow, build_crewai_approval_flow, as_openai_agents_tool, as_crewai_tool, as_claude_agent_tool, as_google_adk_tool, as_google_adk_approval_tool, as_semantic_kernel_function
18
+ from .approval_checkpoints import PostgresApprovalCheckpointStore
19
+
20
+ async_guard_openai_chat = aguard_openai_chat
21
+
22
+ __all__ = [
23
+ "APIError",
24
+ "AgentAuthorizer",
25
+ "AgentTokenProvider",
26
+ "DelegatedToken",
27
+ "RunKilled",
28
+ "TokenError",
29
+ "MCPError",
30
+ "ApprovalRequired",
31
+ "ClaudeAgentApprovalBridge",
32
+ "AsyncAynigma",
33
+ "AuthenticationError",
34
+ "Aynigma",
35
+ "AynigmaError",
36
+ "BlockedError",
37
+ "ExecutionReceipt",
38
+ "OpenAIAgentsApprovalBridge",
39
+ "PostgresApprovalCheckpointStore",
40
+ "SemanticKernelApprovalBridge",
41
+ "QuotaExceededError",
42
+ "RemoteTool",
43
+ "ToolDenied",
44
+ "ToolExecutor",
45
+ "acting_for_user",
46
+ "as_langchain_tool",
47
+ "build_langgraph_approval_workflow",
48
+ "build_crewai_approval_flow",
49
+ "as_openai_agents_tool",
50
+ "as_crewai_tool",
51
+ "as_claude_agent_tool",
52
+ "as_google_adk_tool",
53
+ "as_google_adk_approval_tool",
54
+ "as_semantic_kernel_function",
55
+ "ValidationResult",
56
+ "aguard_openai_chat",
57
+ "aguard_openai_chat_v2",
58
+ "async_guard_openai_chat",
59
+ "guard_openai_chat",
60
+ "guard_openai_chat_v2",
61
+ ]
@@ -0,0 +1,89 @@
1
+ """Exceptions shared by the agent authorizer and the token provider."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ from .client import APIError, AuthenticationError
7
+
8
+
9
+ # Short next steps for the denial codes a developer meets most. The code stays first in the message so
10
+ # anything that parses "Agent tool call denied: <code>" keeps working.
11
+ DENIAL_HINTS = {
12
+ "no_policy": "No rule allows this tool for this agent. Add one on the agent page.",
13
+ "run_mismatch": "This approval belongs to another run. Resume with "
14
+ "AgentAuthorizer(..., run_id=<the run_id from ApprovalRequired>).",
15
+ "payload_mismatch": "The arguments differ from the ones that were approved.",
16
+ "approval_already_used": "This approval was already used. Each approval works once, so ask for a new one.",
17
+ "approval_expired": "This approval expired before it was used. Call the tool again to request a new one.",
18
+ "approval_not_approved": "A reviewer has not approved this call yet.",
19
+ "policy_version_changed": "The policy changed after this approval was requested. "
20
+ "Call the tool again to request a new one.",
21
+ "environment": "The matching rule is limited to other environments. "
22
+ "Check the rule's environments and the agent's environment on the agent page.",
23
+ "rate_limited": "The hourly limit on the matching rule is used up. Wait, or raise the limit on the agent page.",
24
+ }
25
+
26
+
27
+ class ToolDenied(Exception):
28
+ """A tool call was refused. ``reason`` is the backend's reason code; ``hint`` is a short next step or None."""
29
+
30
+ def __init__(self, reason: str):
31
+ hint = DENIAL_HINTS.get(reason)
32
+ super().__init__(f"Agent tool call denied: {reason}" + (f". {hint}" if hint else ""))
33
+ self.reason = reason
34
+ self.hint = hint
35
+
36
+
37
+ class RunKilled(ToolDenied):
38
+ """The agent run was stopped by an administrator or an incident.
39
+
40
+ Cached tokens are dropped when this is raised. The next call starts a new
41
+ run, so catch this to stop the current task, not to retry it blindly.
42
+ """
43
+
44
+ def __init__(self, reason: str = "run_killed"):
45
+ super().__init__(reason)
46
+
47
+
48
+ class ApprovalRequired(Exception):
49
+ """The call needs a human reviewer. Save ``approval_id`` and ``run_id``, then call ``run_tool`` again with
50
+ ``approval_id`` from an authorizer that continues the same run (``AgentAuthorizer(..., run_id=run_id)``).
51
+
52
+ ``run_id`` is the run the approval was raised in (None with the legacy ``X-AGENT-KEY`` header, which has no
53
+ runs). ``tool_name`` is the tool that was refused, when known.
54
+ """
55
+
56
+ def __init__(self, approval_id: int, call_id: str, run_id: Optional[str] = None,
57
+ tool_name: Optional[str] = None):
58
+ subject = f"Tool call '{tool_name}'" if tool_name else "Tool call"
59
+ message = (f"{subject} needs human approval (approval {approval_id}). "
60
+ f"A reviewer can approve it in Agent IAM > Activity. "
61
+ f"Then call run_tool again with approval_id={approval_id}")
62
+ if run_id:
63
+ message += f", from a worker started with run_id='{run_id}'"
64
+ super().__init__(message + ".")
65
+ self.approval_id = approval_id
66
+ self.call_id = call_id
67
+ self.run_id = run_id
68
+ self.tool_name = tool_name
69
+
70
+
71
+ class TokenError(AuthenticationError):
72
+ """The token endpoint refused a request (RFC 6749 error shape)."""
73
+
74
+ def __init__(self, error: str, description: str = "", status_code: Optional[int] = None):
75
+ super().__init__(f"Agent token request failed: {error}" + (f" ({description})" if description else ""))
76
+ self.error = error
77
+ self.error_description = description
78
+ self.status_code = status_code
79
+
80
+
81
+ class MCPError(APIError):
82
+ """The MCP gateway refused a request (HTTP 400); ``payload`` is the JSON-RPC error body."""
83
+
84
+ def __init__(self, payload: dict, status_code: Optional[int] = 400):
85
+ error = payload.get("error") if isinstance(payload, dict) else None
86
+ message = error.get("message") if isinstance(error, dict) else None
87
+ super().__init__(f"MCP request rejected: {message}" if message else "MCP request rejected", status_code)
88
+ self.payload = payload
89
+ self.error = error
@@ -0,0 +1,404 @@
1
+ """Short-lived agent tokens: client credentials, caching, refresh and delegation.
2
+
3
+ ``AgentTokenProvider`` trades an agent key for a 15 minute bearer token and keeps
4
+ it fresh. ``exchange`` turns a human's token (or an already delegated token) into
5
+ a delegated token for this agent (RFC 8693). The key is sent only to the token
6
+ endpoint, never to the tool-call, receipt or MCP endpoints.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import base64
12
+ import json
13
+ import logging
14
+ import os
15
+ import re
16
+ import sys
17
+ import threading
18
+ import time
19
+ from concurrent.futures import Future
20
+ from typing import Any, Callable, Dict, Optional, TypeVar, Union
21
+
22
+ import httpx
23
+
24
+ from .agent_errors import RunKilled, TokenError
25
+ from .client import APIError
26
+
27
+ log = logging.getLogger("aynigma.agents")
28
+ DEFAULT_BASE_URL = "https://api.aynigma.ai/api/v1"
29
+ JWT_TYPE = "urn:ietf:params:oauth:token-type:jwt"
30
+ EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"
31
+
32
+ TokenLike = Union[str, "DelegatedToken", "AgentTokenProvider"]
33
+
34
+
35
+ def resolve_base_url(base_url: Optional[str] = None) -> str:
36
+ return (base_url or os.getenv("AYNIGMA_API_URL") or DEFAULT_BASE_URL).rstrip("/")
37
+
38
+
39
+ def decode_claims(token: str) -> Dict[str, Any]:
40
+ """Decode a JWT payload WITHOUT verifying it. For display and debugging only."""
41
+ try:
42
+ payload = token.split(".")[1]
43
+ payload += "=" * (-len(payload) % 4)
44
+ claims = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
45
+ except (IndexError, ValueError, UnicodeError):
46
+ raise ValueError("Not a decodable JWT") from None
47
+ if not isinstance(claims, dict):
48
+ raise ValueError("Not a decodable JWT")
49
+ return claims
50
+
51
+
52
+ def _token_value(value: TokenLike) -> str:
53
+ return value if isinstance(value, str) else value.token()
54
+
55
+
56
+ T = TypeVar("T")
57
+
58
+
59
+ class _SingleFlight:
60
+ """Run one network call for many concurrent callers; the lock is never held during the call."""
61
+
62
+ def __init__(self, lock: "threading.RLock"):
63
+ self._lock = lock
64
+ self._future: Optional[Future] = None
65
+
66
+ def run(self, fresh: Callable[[], Optional[T]], fn: Callable[[], T]) -> T:
67
+ """``fresh`` runs under the lock and returns a usable cached value, or None."""
68
+ with self._lock:
69
+ hit = fresh()
70
+ if hit is not None:
71
+ return hit
72
+ future = self._future
73
+ if future is None:
74
+ future = Future()
75
+ self._future = future
76
+ leader = True
77
+ else:
78
+ leader = False
79
+ if not leader:
80
+ return future.result()
81
+ try:
82
+ result = fn()
83
+ except BaseException as exc:
84
+ future.set_exception(exc)
85
+ raise
86
+ else:
87
+ future.set_result(result)
88
+ return result
89
+ finally:
90
+ with self._lock:
91
+ self._future = None
92
+
93
+
94
+ class _Issued:
95
+ """One issued token and the moment it should be replaced."""
96
+
97
+ def __init__(self, response: Dict[str, Any], now: float, leeway: float):
98
+ token = response.get("access_token")
99
+ if not isinstance(token, str) or not token:
100
+ raise APIError("Malformed token response")
101
+ try:
102
+ expires_in = float(response.get("expires_in") or 0)
103
+ except (TypeError, ValueError):
104
+ raise APIError("Malformed token response") from None
105
+ if expires_in <= 0:
106
+ try:
107
+ expires_in = max(float(decode_claims(token)["exp"]) - now, 1.0)
108
+ except (KeyError, TypeError, ValueError):
109
+ raise APIError("Malformed token response") from None
110
+ self.token = token
111
+ self.scope = str(response.get("scope") or "")
112
+ self.run_id = response.get("run_id") if isinstance(response.get("run_id"), str) else None
113
+ self.expires_at = now + expires_in
114
+ self.refresh_at = now + expires_in * (1.0 - leeway)
115
+
116
+
117
+ class DelegatedToken:
118
+ """A human's authority delegated to one agent. Re-exchanges itself near expiry."""
119
+
120
+ def __init__(self, provider: "AgentTokenProvider", subject: TokenLike, scope: Optional[str],
121
+ issued: _Issued):
122
+ self._provider = provider
123
+ self._subject = subject
124
+ self._requested_scope = scope
125
+ self._issued = issued
126
+ self._lock = threading.RLock()
127
+ self._flight = _SingleFlight(self._lock)
128
+
129
+ def __repr__(self) -> str: # never show the token
130
+ return "DelegatedToken(scope=%r)" % self._issued.scope
131
+
132
+ def token(self) -> str:
133
+ def fresh() -> Optional[str]:
134
+ issued = self._issued
135
+ return issued.token if self._provider._clock() < issued.refresh_at else None
136
+
137
+ def exchange() -> str:
138
+ issued = self._provider._exchange(self._subject, self._requested_scope)
139
+ with self._lock:
140
+ self._issued = issued
141
+ return issued.token
142
+ return self._flight.run(fresh, exchange)
143
+
144
+ def invalidate(self, failed_token: Optional[str] = None) -> None:
145
+ """Force the next ``token()`` to exchange again (after a 401).
146
+
147
+ With ``failed_token``, only if that is still the current token.
148
+ """
149
+ with self._lock:
150
+ if failed_token is None or self._issued.token == failed_token:
151
+ self._issued.refresh_at = 0.0
152
+
153
+ @property
154
+ def expires_at(self) -> float:
155
+ return self._issued.expires_at
156
+
157
+ @property
158
+ def scope(self) -> str:
159
+ return self._issued.scope
160
+
161
+ @property
162
+ def expired(self) -> bool:
163
+ return self._provider._clock() >= self._issued.expires_at
164
+
165
+ @property
166
+ def provider(self) -> "AgentTokenProvider":
167
+ return self._provider
168
+
169
+ def claims(self) -> Dict[str, Any]:
170
+ return decode_claims(self._issued.token)
171
+
172
+
173
+ _ACTOR_OR_RUN = re.compile(r"\b(actor|run)")
174
+
175
+
176
+ class AgentTokenProvider:
177
+ """Fetch, cache and refresh a client credentials token for one agent.
178
+
179
+ The token is fetched on first use, reused, and replaced once ``leeway`` (a
180
+ fraction, default 0.2) of its lifetime is left. Thread safe: concurrent
181
+ callers share one fetch, and no lock is held during network calls.
182
+
183
+ A run that ended (idle) is replaced by a new one transparently (logged at
184
+ INFO). A run that was killed raises ``RunKilled``.
185
+ """
186
+
187
+ def __init__(self, agent_id: str, agent_key: str, base_url: Optional[str] = None,
188
+ http_client: Optional[httpx.Client] = None, run_id: Optional[str] = None,
189
+ leeway: float = 0.2, timeout: float = 10.0,
190
+ clock: Callable[[], float] = time.time):
191
+ if not agent_id or not agent_key:
192
+ raise ValueError("agent_id and agent_key are required")
193
+ if not 0 <= leeway < 1:
194
+ raise ValueError("leeway must be at least 0 and below 1")
195
+ self.agent_id = str(agent_id)
196
+ self._key = agent_key
197
+ self.base_url = resolve_base_url(base_url)
198
+ self._http = http_client or httpx.Client(timeout=timeout)
199
+ self._owns_http = http_client is None
200
+ self._leeway = leeway
201
+ self._clock = clock
202
+ self._run_id = run_id
203
+ self._issued: Optional[_Issued] = None
204
+ self._lock = threading.RLock()
205
+ self._flight = _SingleFlight(self._lock)
206
+
207
+ def __repr__(self) -> str: # never show the key
208
+ return f"AgentTokenProvider(agent_id={self.agent_id!r})"
209
+
210
+ # -- token endpoint -------------------------------------------------
211
+
212
+ def _post_token(self, form: Dict[str, str]) -> Dict[str, Any]:
213
+ try:
214
+ response = self._http.post(f"{self.base_url}/agents/oauth/token", data=form,
215
+ headers={"Accept": "application/json"})
216
+ except httpx.HTTPError:
217
+ raise APIError("Agent token service unavailable") from None
218
+ try:
219
+ payload = response.json()
220
+ except (ValueError, UnicodeDecodeError):
221
+ raise APIError("Malformed token response", response.status_code) from None
222
+ if not isinstance(payload, dict):
223
+ raise APIError("Malformed token response", response.status_code)
224
+ if response.status_code != 200:
225
+ error = payload.get("error")
226
+ if isinstance(error, str) and error:
227
+ raise TokenError(error, str(payload.get("error_description") or ""), response.status_code)
228
+ raise APIError("Agent token request failed", response.status_code)
229
+ return payload
230
+
231
+ def _forget_run(self) -> None:
232
+ with self._lock:
233
+ self._run_id = None
234
+ self._issued = None
235
+
236
+ def _fetch(self, continue_run: bool = True) -> _Issued:
237
+ with self._lock:
238
+ previous = self._run_id
239
+ run_id = previous if continue_run else None
240
+ form = {"grant_type": "client_credentials", "client_id": self.agent_id,
241
+ "client_secret": self._key}
242
+ if run_id:
243
+ form["run_id"] = run_id
244
+ try:
245
+ payload = self._post_token(form)
246
+ except TokenError as exc:
247
+ if exc.error != "invalid_grant" or not run_id:
248
+ raise
249
+ self._forget_run()
250
+ if "run_killed" in exc.error_description:
251
+ raise RunKilled("run_killed") from None
252
+ # The run is over for another reason: start a new one.
253
+ del form["run_id"]
254
+ payload = self._post_token(form)
255
+ issued = _Issued(payload, self._clock(), self._leeway)
256
+ if previous and issued.run_id and issued.run_id != previous:
257
+ log.info("Agent run %s ended; continuing in new run %s", previous, issued.run_id)
258
+ with self._lock:
259
+ if issued.run_id:
260
+ self._run_id = issued.run_id
261
+ self._issued = issued
262
+ return issued
263
+
264
+ def _refresh(self, failed_token: Optional[str], continue_run: bool = True) -> _Issued:
265
+ def fresh() -> Optional[_Issued]:
266
+ issued = self._issued
267
+ if issued is None:
268
+ return None
269
+ if failed_token is None:
270
+ return issued if self._clock() < issued.refresh_at else None
271
+ return issued if issued.token != failed_token else None
272
+ return self._flight.run(fresh, lambda: self._fetch(continue_run))
273
+
274
+ def _current(self) -> _Issued:
275
+ return self._refresh(None)
276
+
277
+ # -- public API -----------------------------------------------------
278
+
279
+ def token(self) -> str:
280
+ return self._current().token
281
+
282
+ def cached_token(self) -> Optional[str]:
283
+ """The cached token, or None. Never touches the network."""
284
+ issued = self._issued
285
+ return issued.token if issued is not None else None
286
+
287
+ @property
288
+ def run_id(self) -> Optional[str]:
289
+ """Run of the current token, or the run being continued. Never touches the network."""
290
+ issued = self._issued
291
+ return (issued.run_id if issued is not None else None) or self._run_id
292
+
293
+ @property
294
+ def scope(self) -> Optional[str]:
295
+ """Scope of the current token, or None before the first fetch. Never touches the network."""
296
+ issued = self._issued
297
+ return issued.scope if issued is not None else None
298
+
299
+ @property
300
+ def expires_at(self) -> Optional[float]:
301
+ issued = self._issued
302
+ return issued.expires_at if issued is not None else None
303
+
304
+ def claims(self) -> Dict[str, Any]:
305
+ """Decoded claims of the current token. Not verified; do not trust for decisions."""
306
+ return decode_claims(self.token())
307
+
308
+ def invalidate(self, run_killed: bool = False) -> None:
309
+ """Drop the cached token. After a kill, also forget the run so the next token starts a new one."""
310
+ with self._lock:
311
+ self._issued = None
312
+ if run_killed:
313
+ self._run_id = None
314
+
315
+ def refresh_after_unauthorized(self, failed_token: Optional[str] = None,
316
+ new_run: bool = False) -> str:
317
+ """Replace the token after the API answered 401.
318
+
319
+ Does nothing if ``failed_token`` is no longer the cached token (another
320
+ caller already refreshed), and concurrent callers share one fetch.
321
+ Continues the same run; if that run was killed, raises ``RunKilled``.
322
+ ``new_run=True`` starts a new run (the old one ended).
323
+ """
324
+ return self._refresh(failed_token if failed_token is not None else self.cached_token(),
325
+ continue_run=not new_run).token
326
+
327
+ def _exchange(self, subject: TokenLike, scope: Optional[str]) -> _Issued:
328
+ def attempt() -> Dict[str, Any]:
329
+ actor = self.token()
330
+ form = {
331
+ "grant_type": EXCHANGE_GRANT,
332
+ "subject_token": _token_value(subject), "subject_token_type": JWT_TYPE,
333
+ "actor_token": actor, "actor_token_type": JWT_TYPE,
334
+ }
335
+ if scope:
336
+ form["scope"] = scope
337
+ try:
338
+ return self._post_token(form)
339
+ except TokenError as exc:
340
+ exc.actor_token = actor # type: ignore[attr-defined]
341
+ raise
342
+ try:
343
+ payload = attempt()
344
+ except TokenError as exc:
345
+ description = exc.error_description.lower()
346
+ if exc.error != "invalid_grant":
347
+ raise
348
+ if "run_killed" in description:
349
+ self.invalidate(run_killed=True)
350
+ raise RunKilled("run_killed") from None
351
+ if not _ACTOR_OR_RUN.search(description):
352
+ raise # the user's token was refused: a new actor token would not help
353
+ self.refresh_after_unauthorized(getattr(exc, "actor_token", None))
354
+ payload = attempt()
355
+ return _Issued(payload, self._clock(), self._leeway)
356
+
357
+ def exchange(self, subject_token: TokenLike, scope: Optional[str] = None) -> DelegatedToken:
358
+ """Delegate a user JWT (or another delegated token) to this agent.
359
+
360
+ ``scope`` can only narrow what the user and the agent already share.
361
+ """
362
+ if isinstance(subject_token, str) and not subject_token:
363
+ raise ValueError("subject_token is required")
364
+ return DelegatedToken(self, subject_token, scope, self._exchange(subject_token, scope))
365
+
366
+ def close(self) -> None:
367
+ if self._owns_http:
368
+ self._http.close()
369
+
370
+
371
+ def _truthy(value: Optional[str]) -> bool:
372
+ return (value or "").strip().lower() in ("1", "true", "yes", "on")
373
+
374
+
375
+ def main(argv=None) -> int:
376
+ parser = argparse.ArgumentParser(
377
+ prog="aynigma-agents-token",
378
+ description="Print an agent token for debugging. Prefer the AYNIGMA_AGENT_KEY variable over --key, "
379
+ "which is visible in the process list.")
380
+ parser.add_argument("--agent-id", default=os.getenv("AYNIGMA_AGENT_ID"))
381
+ parser.add_argument("--key", default=os.getenv("AYNIGMA_AGENT_KEY"))
382
+ parser.add_argument("--base-url", default=os.getenv("AYNIGMA_API_URL") or DEFAULT_BASE_URL)
383
+ parser.add_argument("--claims", action="store_true", help="print the decoded claims instead of the token")
384
+ args = parser.parse_args(argv)
385
+ if not args.agent_id:
386
+ parser.error("--agent-id (or AYNIGMA_AGENT_ID) is required")
387
+ if not args.key:
388
+ parser.error("--key (or AYNIGMA_AGENT_KEY) is required")
389
+ provider = AgentTokenProvider(args.agent_id, args.key, args.base_url)
390
+ try:
391
+ if args.claims:
392
+ print(json.dumps(provider.claims(), indent=2, sort_keys=True))
393
+ else:
394
+ print(provider.token())
395
+ except (TokenError, APIError) as exc:
396
+ print(f"error: {exc}", file=sys.stderr)
397
+ return 1
398
+ finally:
399
+ provider.close()
400
+ return 0
401
+
402
+
403
+ if __name__ == "__main__":
404
+ raise SystemExit(main())