lelu-agent-auth-sdk 0.1.0__tar.gz

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 (31) hide show
  1. lelu_agent_auth_sdk-0.1.0/.gitignore +41 -0
  2. lelu_agent_auth_sdk-0.1.0/PKG-INFO +67 -0
  3. lelu_agent_auth_sdk-0.1.0/README.md +44 -0
  4. lelu_agent_auth_sdk-0.1.0/auth_pe/__init__.py +66 -0
  5. lelu_agent_auth_sdk-0.1.0/auth_pe/autogpt_plugin.py +63 -0
  6. lelu_agent_auth_sdk-0.1.0/auth_pe/client.py +208 -0
  7. lelu_agent_auth_sdk-0.1.0/auth_pe/crewai.py +189 -0
  8. lelu_agent_auth_sdk-0.1.0/auth_pe/fastapi.py +101 -0
  9. lelu_agent_auth_sdk-0.1.0/auth_pe/langgraph.py +191 -0
  10. lelu_agent_auth_sdk-0.1.0/auth_pe/middleware.py +53 -0
  11. lelu_agent_auth_sdk-0.1.0/auth_pe/models.py +126 -0
  12. lelu_agent_auth_sdk-0.1.0/autogpt_plugin/plugin_config.json +18 -0
  13. lelu_agent_auth_sdk-0.1.0/lelu/__init__.py +7 -0
  14. lelu_agent_auth_sdk-0.1.0/lelu/autogpt_plugin.py +1 -0
  15. lelu_agent_auth_sdk-0.1.0/lelu/client.py +1 -0
  16. lelu_agent_auth_sdk-0.1.0/lelu/crewai.py +1 -0
  17. lelu_agent_auth_sdk-0.1.0/lelu/fastapi.py +1 -0
  18. lelu_agent_auth_sdk-0.1.0/lelu/langgraph.py +1 -0
  19. lelu_agent_auth_sdk-0.1.0/lelu/middleware.py +1 -0
  20. lelu_agent_auth_sdk-0.1.0/lelu/models.py +1 -0
  21. lelu_agent_auth_sdk-0.1.0/prism/__init__.py +7 -0
  22. lelu_agent_auth_sdk-0.1.0/prism/autogpt_plugin.py +1 -0
  23. lelu_agent_auth_sdk-0.1.0/prism/client.py +1 -0
  24. lelu_agent_auth_sdk-0.1.0/prism/crewai.py +1 -0
  25. lelu_agent_auth_sdk-0.1.0/prism/fastapi.py +1 -0
  26. lelu_agent_auth_sdk-0.1.0/prism/langgraph.py +1 -0
  27. lelu_agent_auth_sdk-0.1.0/prism/middleware.py +1 -0
  28. lelu_agent_auth_sdk-0.1.0/prism/models.py +1 -0
  29. lelu_agent_auth_sdk-0.1.0/pyproject.toml +51 -0
  30. lelu_agent_auth_sdk-0.1.0/tests/test_client.py +265 -0
  31. lelu_agent_auth_sdk-0.1.0/tests/test_langgraph.py +105 -0
@@ -0,0 +1,41 @@
1
+ # Binaries
2
+ bin/
3
+ *.exe
4
+
5
+ # Go
6
+ engine/gen/
7
+ coverage.out
8
+ *.test
9
+
10
+ # Node
11
+ node_modules/
12
+ **/node_modules/
13
+ **/.next/
14
+ *.tsbuildinfo
15
+ sdk/typescript/node_modules/
16
+ sdk/typescript/dist/
17
+ platform/ui/node_modules/
18
+ platform/ui/.next/
19
+
20
+ # Python
21
+ sdk/python/__pycache__/
22
+ sdk/python/*.egg-info/
23
+ sdk/python/.pytest_cache/
24
+ sdk/python/.mypy_cache/
25
+ __pycache__/
26
+ *.pyc
27
+
28
+ # Docker
29
+ .docker/
30
+
31
+ # Local caches
32
+ engine/.gomodcache/
33
+
34
+ # Local env
35
+ .env
36
+ .env.*
37
+ !.env.example
38
+
39
+ # OS
40
+ .DS_Store
41
+ Thumbs.db
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: lelu-agent-auth-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Lelu — the confidence-aware authorization engine for autonomous AI agents
5
+ Project-URL: Homepage, https://github.com/lelu-auth/lelu
6
+ Project-URL: Repository, https://github.com/lelu-auth/lelu
7
+ License: MIT
8
+ Keywords: ai-agents,auth,authorization,langchain,langgraph,permissions
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: httpx>=0.27.0
11
+ Requires-Dist: pydantic>=2.7.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: mypy>=1.10.0; extra == 'dev'
14
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
15
+ Requires-Dist: pytest-httpx>=0.30.0; extra == 'dev'
16
+ Requires-Dist: pytest>=8.2.0; extra == 'dev'
17
+ Requires-Dist: ruff>=0.4.0; extra == 'dev'
18
+ Provides-Extra: fastapi
19
+ Requires-Dist: fastapi>=0.111.0; extra == 'fastapi'
20
+ Provides-Extra: langgraph
21
+ Requires-Dist: langgraph>=0.1.0; extra == 'langgraph'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # Lelu · Python SDK
25
+
26
+ Python client for [Lelu](https://github.com/lelu-auth/lelu) — the confidence-aware authorization engine for autonomous AI agents.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install lelu-agent-auth-sdk
32
+ ```
33
+
34
+ ## Quick start
35
+
36
+ ```python
37
+ import asyncio
38
+ from lelu import LeluClient, AgentAuthRequest, AgentContext
39
+
40
+ async def main():
41
+ async with LeluClient(base_url="http://localhost:8082") as client:
42
+ result = await client.agent_authorize(AgentAuthRequest(
43
+ actor="invoice_bot",
44
+ action="invoice:create",
45
+ context=AgentContext(
46
+ confidence=0.92,
47
+ acting_for="user_123",
48
+ ),
49
+ ))
50
+ print(result.allowed, result.reason)
51
+
52
+ asyncio.run(main())
53
+ ```
54
+
55
+ ## API
56
+
57
+ | Method | Description |
58
+ |---|---|
59
+ | `agent_authorize(req)` | Confidence-aware agent authorization |
60
+ | `authorize(req)` | Human RBAC authorization |
61
+ | `mint_token(req)` | Mint a JIT-scoped JWT |
62
+ | `revoke_token(token_id)` | Revoke a token immediately |
63
+ | `is_healthy()` | Health-check the engine |
64
+
65
+ ## License
66
+
67
+ MIT
@@ -0,0 +1,44 @@
1
+ # Lelu · Python SDK
2
+
3
+ Python client for [Lelu](https://github.com/lelu-auth/lelu) — the confidence-aware authorization engine for autonomous AI agents.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install lelu-agent-auth-sdk
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```python
14
+ import asyncio
15
+ from lelu import LeluClient, AgentAuthRequest, AgentContext
16
+
17
+ async def main():
18
+ async with LeluClient(base_url="http://localhost:8082") as client:
19
+ result = await client.agent_authorize(AgentAuthRequest(
20
+ actor="invoice_bot",
21
+ action="invoice:create",
22
+ context=AgentContext(
23
+ confidence=0.92,
24
+ acting_for="user_123",
25
+ ),
26
+ ))
27
+ print(result.allowed, result.reason)
28
+
29
+ asyncio.run(main())
30
+ ```
31
+
32
+ ## API
33
+
34
+ | Method | Description |
35
+ |---|---|
36
+ | `agent_authorize(req)` | Confidence-aware agent authorization |
37
+ | `authorize(req)` | Human RBAC authorization |
38
+ | `mint_token(req)` | Mint a JIT-scoped JWT |
39
+ | `revoke_token(token_id)` | Revoke a token immediately |
40
+ | `is_healthy()` | Health-check the engine |
41
+
42
+ ## License
43
+
44
+ MIT
@@ -0,0 +1,66 @@
1
+ """
2
+ Lelu Python SDK.
3
+
4
+ Quick start::
5
+
6
+ from lelu import LeluClient, AgentAuthRequest, AgentContext
7
+
8
+ async with LeluClient(base_url="http://localhost:8080") as lelu:
9
+ decision = await lelu.agent_authorize(
10
+ AgentAuthRequest(
11
+ actor="invoice_bot",
12
+ action="approve_refunds",
13
+ context=AgentContext(confidence=0.92, acting_for="user_123"),
14
+ )
15
+ )
16
+ if not decision.allowed:
17
+ print(decision.reason)
18
+ """
19
+
20
+ from .client import LeluClient
21
+ from .autogpt_plugin import LeluAutoGPTPlugin
22
+ from .middleware import AgentMiddleware
23
+ from .models import (
24
+ AgentAuthDecision,
25
+ AgentAuthRequest,
26
+ AgentContext,
27
+ AuthDecision,
28
+ AuthEngineError,
29
+ AuthRequest,
30
+ DelegateScopeRequest,
31
+ DelegateScopeResult,
32
+ MintTokenRequest,
33
+ MintTokenResult,
34
+ RevokeTokenResult,
35
+ )
36
+
37
+ # CrewAI integration — requires `pip install crewai`
38
+ try:
39
+ from .crewai import LeluTool, PermissionDeniedError as CrewAIPermissionDeniedError # noqa: F401
40
+ except ImportError:
41
+ pass # crewai not installed; LeluTool not available
42
+
43
+ __all__ = [
44
+ "LeluClient",
45
+ "LeluAutoGPTPlugin",
46
+ "AgentMiddleware",
47
+ # CrewAI
48
+ "LeluTool",
49
+ "CrewAIPermissionDeniedError",
50
+ # Requests
51
+ "AuthRequest",
52
+ "AgentAuthRequest",
53
+ "AgentContext",
54
+ "MintTokenRequest",
55
+ "DelegateScopeRequest",
56
+ # Decisions
57
+ "AuthDecision",
58
+ "AgentAuthDecision",
59
+ "MintTokenResult",
60
+ "DelegateScopeResult",
61
+ "RevokeTokenResult",
62
+ # Errors
63
+ "AuthEngineError",
64
+ ]
65
+
66
+ __version__ = "0.1.0"
@@ -0,0 +1,63 @@
1
+ """AutoGPT plugin scaffold for Lelu Auth Permission Engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from .client import LeluClient
9
+ from .models import AgentAuthRequest, AgentContext
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class LeluAutoGPTPlugin:
14
+ """
15
+ Minimal plugin helper for AutoGPT-style tool execution guards.
16
+
17
+ This is framework-agnostic so it can be plugged into different AutoGPT
18
+ runtimes without requiring a hard dependency.
19
+ """
20
+
21
+ client: LeluClient
22
+ actor: str
23
+
24
+ async def can_execute(
25
+ self,
26
+ action: str,
27
+ *,
28
+ confidence: float,
29
+ acting_for: str = "",
30
+ scope: str = "",
31
+ resource: dict[str, Any] | None = None,
32
+ ) -> tuple[bool, str]:
33
+ req = AgentAuthRequest(
34
+ actor=self.actor,
35
+ action=action,
36
+ resource=resource or {},
37
+ context=AgentContext(
38
+ confidence=confidence,
39
+ acting_for=acting_for,
40
+ scope=scope,
41
+ ),
42
+ )
43
+ decision = await self.client.agent_authorize(req)
44
+ return decision.allowed, decision.reason
45
+
46
+ async def enforce(
47
+ self,
48
+ action: str,
49
+ *,
50
+ confidence: float,
51
+ acting_for: str = "",
52
+ scope: str = "",
53
+ resource: dict[str, Any] | None = None,
54
+ ) -> None:
55
+ allowed, reason = await self.can_execute(
56
+ action,
57
+ confidence=confidence,
58
+ acting_for=acting_for,
59
+ scope=scope,
60
+ resource=resource,
61
+ )
62
+ if not allowed:
63
+ raise PermissionError(reason)
@@ -0,0 +1,208 @@
1
+ """Auth Permission Engine — async Python client (httpx-backed)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from .models import (
11
+ AgentAuthDecision,
12
+ AgentAuthRequest,
13
+ AuthDecision,
14
+ AuthEngineError,
15
+ AuthRequest,
16
+ DelegateScopeRequest,
17
+ DelegateScopeResult,
18
+ MintTokenRequest,
19
+ MintTokenResult,
20
+ RevokeTokenResult,
21
+ )
22
+
23
+
24
+ class LeluClient:
25
+ """
26
+ Async client for the Auth Permission Engine HTTP API.
27
+
28
+ Usage::
29
+
30
+ async with LeluClient(base_url="http://localhost:8080") as lelu:
31
+ decision = await lelu.agent_authorize(
32
+ AgentAuthRequest(
33
+ actor="invoice_bot",
34
+ action="approve_refunds",
35
+ context=AgentContext(confidence=0.92, acting_for="user_123"),
36
+ )
37
+ )
38
+ if not decision.allowed:
39
+ print(decision.reason)
40
+
41
+ Or without a context manager::
42
+
43
+ lelu = LeluClient()
44
+ decision = await lelu.authorize(AuthRequest(user_id="u1", action="view_invoices"))
45
+ await lelu.aclose()
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ base_url: str = "http://localhost:8080",
51
+ timeout: float = 5.0,
52
+ api_key: str | None = None,
53
+ ) -> None:
54
+ headers: dict[str, str] = {"Content-Type": "application/json"}
55
+ if api_key:
56
+ headers["Authorization"] = f"Bearer {api_key}"
57
+
58
+ self._client = httpx.AsyncClient(
59
+ base_url=base_url.rstrip("/"),
60
+ headers=headers,
61
+ timeout=timeout,
62
+ )
63
+
64
+ # ── Context manager ───────────────────────────────────────────────────────
65
+
66
+ async def __aenter__(self) -> "LeluClient":
67
+ return self
68
+
69
+ async def __aexit__(self, *_: Any) -> None:
70
+ await self.aclose()
71
+
72
+ async def aclose(self) -> None:
73
+ await self._client.aclose()
74
+
75
+ # ── Human authorization ───────────────────────────────────────────────────
76
+
77
+ async def authorize(self, req: AuthRequest) -> AuthDecision:
78
+ """Check whether a human user is permitted to perform an action."""
79
+ payload = {
80
+ "user_id": req.user_id,
81
+ "action": req.action,
82
+ "resource": req.resource,
83
+ }
84
+ data = await self._post("/v1/authorize", payload)
85
+ return AuthDecision(
86
+ allowed=data["allowed"],
87
+ reason=data["reason"],
88
+ trace_id=data["trace_id"],
89
+ )
90
+
91
+ # ── Agent authorization ───────────────────────────────────────────────────
92
+
93
+ async def agent_authorize(self, req: AgentAuthRequest) -> AgentAuthDecision:
94
+ """
95
+ Check whether an AI agent is permitted to perform an action.
96
+
97
+ The confidence score in ``req.context`` is passed through the
98
+ Confidence-Aware Auth gate ★ before policy evaluation.
99
+ """
100
+ payload = {
101
+ "actor": req.actor,
102
+ "action": req.action,
103
+ "resource": req.resource,
104
+ "confidence": req.context.confidence,
105
+ "acting_for": req.context.acting_for,
106
+ "scope": req.context.scope,
107
+ }
108
+ data = await self._post("/v1/agent/authorize", payload)
109
+ return AgentAuthDecision(
110
+ allowed=data["allowed"],
111
+ reason=data["reason"],
112
+ trace_id=data["trace_id"],
113
+ downgraded_scope=data.get("downgraded_scope"),
114
+ requires_human_review=data.get("requires_human_review", False),
115
+ confidence_used=data.get("confidence_used", 0.0),
116
+ )
117
+
118
+ # ── JIT token minting ─────────────────────────────────────────────────────
119
+
120
+ async def mint_token(self, req: MintTokenRequest) -> MintTokenResult:
121
+ """Mint a scoped JIT token for an agent. Default TTL is 60 seconds."""
122
+ payload = {
123
+ "scope": req.scope,
124
+ "acting_for": req.acting_for,
125
+ "ttl_seconds": req.ttl_seconds or 60,
126
+ }
127
+ data = await self._post("/v1/tokens/mint", payload)
128
+ return MintTokenResult(
129
+ token=data["token"],
130
+ token_id=data["token_id"],
131
+ expires_at=datetime.fromtimestamp(data["expires_at"], tz=timezone.utc),
132
+ )
133
+
134
+ # ── Token revocation ──────────────────────────────────────────────────────
135
+
136
+ async def revoke_token(self, token_id: str) -> RevokeTokenResult:
137
+ """Immediately revoke a JIT token by its ID."""
138
+ resp = await self._client.delete(f"/v1/tokens/{token_id}")
139
+ await self._raise_for_status(resp)
140
+ return RevokeTokenResult(**resp.json())
141
+
142
+ # ── Multi-agent delegation ────────────────────────────────────────
143
+
144
+ async def delegate_scope(self, req: DelegateScopeRequest) -> DelegateScopeResult:
145
+ """
146
+ Delegate a constrained sub-scope from one agent to another.
147
+
148
+ Validates the delegation rule in the policy, caps the TTL, and mints a
149
+ child JIT token scoped to the granted actions.
150
+
151
+ Confidence-Aware: the delegator's confidence score is checked against
152
+ ``require_confidence_above`` in the policy before delegation is granted.
153
+ """
154
+ payload = {
155
+ "delegator": req.delegator,
156
+ "delegatee": req.delegatee,
157
+ "scoped_to": req.scoped_to,
158
+ "ttl_seconds": req.ttl_seconds or 60,
159
+ "confidence": req.confidence,
160
+ "acting_for": req.acting_for or "",
161
+ "tenant_id": req.tenant_id or "",
162
+ }
163
+ data = await self._post("/v1/agent/delegate", payload)
164
+ from datetime import datetime, timezone
165
+ return DelegateScopeResult(
166
+ token=data["token"],
167
+ token_id=data["token_id"],
168
+ expires_at=datetime.fromtimestamp(data["expires_at"], tz=timezone.utc),
169
+ delegator=data["delegator"],
170
+ delegatee=data["delegatee"],
171
+ granted_scopes=data["granted_scopes"],
172
+ trace_id=data["trace_id"],
173
+ )
174
+
175
+ # ── Health check ──────────────────────────────────────────────────────────
176
+
177
+ async def is_healthy(self) -> bool:
178
+ """Return True if the engine sidecar is reachable and healthy."""
179
+ try:
180
+ resp = await self._client.get("/healthz")
181
+ return resp.status_code == 200 and resp.json().get("status") == "ok"
182
+ except httpx.HTTPError:
183
+ return False
184
+
185
+ # ── HTTP helpers ──────────────────────────────────────────────────────────
186
+
187
+ async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
188
+ resp = await self._client.post(path, json=payload)
189
+ await self._raise_for_status(resp)
190
+ return resp.json() # type: ignore[no-any-return]
191
+
192
+ @staticmethod
193
+ async def _raise_for_status(resp: httpx.Response) -> None:
194
+ if not resp.is_error:
195
+ return
196
+ try:
197
+ detail = resp.json().get("error", resp.text)
198
+ except Exception:
199
+ detail = resp.text
200
+ raise AuthEngineError(
201
+ message=str(detail),
202
+ status=resp.status_code,
203
+ details=resp.text,
204
+ )
205
+
206
+
207
+ # Backward compatibility alias.
208
+ LeluClient = LeluClient
@@ -0,0 +1,189 @@
1
+ """
2
+ Lelu integration for CrewAI agents.
3
+
4
+ Provides ``LeluTool`` — a CrewAI-compatible tool base class that gates every
5
+ ``_run()`` call through Lelu's Confidence-Aware Auth before execution.
6
+
7
+ Usage
8
+ -----
9
+ .. code-block:: python
10
+
11
+ from crewai.tools import BaseTool
12
+ from lelu.crewai import LeluTool
13
+ from lelu import LeluClient
14
+
15
+ client = LeluClient(base_url="http://localhost:8080")
16
+
17
+ class RefundTool(LeluTool):
18
+ name: str = "process_refund"
19
+ description: str = "Process a customer refund by invoice ID."
20
+ actor: str = "invoice_bot"
21
+ action: str = "invoice:refund"
22
+ confidence: float = 0.92 # set dynamically per-call if needed
23
+
24
+ def _execute(self, invoice_id: str) -> str:
25
+ # Your real tool logic here
26
+ return f"Refund processed for invoice {invoice_id}"
27
+
28
+ tool = RefundTool(lelu_client=client)
29
+
30
+ # Use in a CrewAI agent
31
+ from crewai import Agent, Task, Crew
32
+ agent = Agent(role="Finance Bot", tools=[tool], ...)
33
+
34
+ Notes
35
+ -----
36
+ - ``_execute()`` is the method you override (not ``_run()``).
37
+ - If Lelu denies the action, ``_run()`` returns a structured refusal string
38
+ that the LLM can use to self-correct.
39
+ - If the action requires human review, it is queued automatically and a
40
+ pending message is returned.
41
+ - Set ``throw_on_deny=True`` to raise ``PermissionDeniedError`` instead.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import asyncio
47
+ import logging
48
+ from typing import Any
49
+
50
+ from pydantic import Field
51
+
52
+ from .client import LeluClient
53
+ from .models import AgentAuthRequest, AgentContext
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+ # ─── Attempt to import CrewAI ────────────────────────────────────────────────
58
+
59
+ try:
60
+ from crewai.tools import BaseTool as CrewAIBaseTool
61
+
62
+ _CREWAI_AVAILABLE = True
63
+ except ImportError:
64
+ _CREWAI_AVAILABLE = False
65
+ CrewAIBaseTool = object
66
+
67
+
68
+ # ─── Exceptions ───────────────────────────────────────────────────────────────
69
+
70
+
71
+ class PermissionDeniedError(Exception):
72
+ """Raised by LeluTool when ``throw_on_deny=True`` and Lelu denies."""
73
+
74
+ def __init__(self, message: str, reason: str) -> None:
75
+ super().__init__(message)
76
+ self.reason = reason
77
+
78
+
79
+ # ─── LeluTool ────────────────────────────────────────────────────────────────
80
+
81
+
82
+ class LeluTool(CrewAIBaseTool): # type: ignore[misc]
83
+ """CrewAI ``BaseTool`` with Lelu Confidence-Aware Auth.
84
+
85
+ Subclass this and implement ``_execute()`` instead of ``_run()``.
86
+ Every call to ``_run()`` is intercepted and gated through Lelu first.
87
+
88
+ Attributes
89
+ ----------
90
+ actor:
91
+ The Lelu agent scope / actor name registered in your ``auth.yaml``.
92
+ action:
93
+ The permission string being checked (e.g. ``"invoice:refund"``).
94
+ confidence:
95
+ LLM confidence score for this invocation (0.0–1.0). Can be set
96
+ dynamically before calling the tool.
97
+ throw_on_deny:
98
+ If ``True``, raise :class:`PermissionDeniedError` on denial.
99
+ If ``False`` (default), return a structured refusal string for LLM
100
+ self-correction.
101
+ acting_for:
102
+ Optional user ID the agent is acting on behalf of.
103
+ """
104
+
105
+ # Lelu-specific fields (Pydantic v2 model fields)
106
+ actor: str = Field(..., description="Lelu agent actor / scope name")
107
+ action: str = Field(..., description="Permission string to authorize")
108
+ confidence: float = Field(default=1.0, ge=0.0, le=1.0, description="LLM confidence score")
109
+ throw_on_deny: bool = Field(default=False, description="Raise on deny instead of returning refusal")
110
+ acting_for: str | None = Field(default=None, description="User the agent acts on behalf of")
111
+
112
+ # LeluClient is injected at construction time.
113
+ model_config = {"arbitrary_types_allowed": True}
114
+ _lelu: LeluClient
115
+
116
+ def __init__(self, lelu_client: LeluClient, **data: Any) -> None:
117
+ if not _CREWAI_AVAILABLE:
118
+ raise ImportError(
119
+ "crewai is not installed. Install it with: pip install crewai"
120
+ )
121
+ super().__init__(**data)
122
+ self._lelu = lelu_client
123
+
124
+ # ── Intercept CrewAI's _run ───────────────────────────────────────────────
125
+
126
+ def _run(self, *args: Any, **kwargs: Any) -> str:
127
+ """
128
+ Gate the tool call through Lelu before executing ``_execute()``.
129
+ Called by CrewAI's agent loop automatically.
130
+ """
131
+ try:
132
+ decision = asyncio.run(
133
+ self._lelu.agent_authorize(
134
+ AgentAuthRequest(
135
+ actor=self.actor,
136
+ action=self.action,
137
+ context=AgentContext(
138
+ confidence=self.confidence,
139
+ acting_for=self.acting_for,
140
+ ),
141
+ )
142
+ )
143
+ )
144
+ except Exception as exc: # noqa: BLE001
145
+ msg = f"[Lelu] Authorization check failed for '{self.name}': {exc}"
146
+ logger.error("lelu: %s", msg)
147
+ if self.throw_on_deny:
148
+ raise PermissionDeniedError(msg, str(exc)) from exc
149
+ return msg
150
+
151
+ # ── Human review required ──────────────────────────────────────────
152
+ if decision.requires_human_review:
153
+ logger.info(
154
+ "lelu: tool=%s queued for human review reason=%r",
155
+ self.name,
156
+ decision.reason,
157
+ )
158
+ return (
159
+ f"[Lelu] Action '{self.name}' is queued for human review. "
160
+ f"Reason: {decision.reason}. Please wait for approval before retrying."
161
+ )
162
+
163
+ # ── Hard deny ─────────────────────────────────────────────────────
164
+ if not decision.allowed:
165
+ msg = (
166
+ f"[Lelu] Action '{self.name}' was denied for agent '{self.actor}'. "
167
+ f"Reason: {decision.reason}."
168
+ )
169
+ if decision.downgraded_scope:
170
+ msg += f" Downgraded to: {decision.downgraded_scope}."
171
+ logger.warning("lelu: %s", msg)
172
+ if self.throw_on_deny:
173
+ raise PermissionDeniedError(msg, decision.reason)
174
+ return msg
175
+
176
+ # ── Authorized — run the real tool ────────────────────────────────
177
+ logger.debug(
178
+ "lelu: tool=%s authorized confidence=%.2f trace_id=%s",
179
+ self.name,
180
+ decision.confidence_used,
181
+ decision.trace_id,
182
+ )
183
+ return self._execute(*args, **kwargs)
184
+
185
+ def _execute(self, *args: Any, **kwargs: Any) -> str:
186
+ """Override this method with your actual tool logic."""
187
+ raise NotImplementedError(
188
+ f"LeluTool subclass '{type(self).__name__}' must implement _execute()"
189
+ )