agentguardproxy 0.5.0__tar.gz → 0.5.1__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.
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/PKG-INFO +1 -1
- agentguardproxy-0.5.1/agentguard/adapters/crewai.py +637 -0
- agentguardproxy-0.5.1/agentguard/adapters/langchain.py +635 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/agentguard/adapters/mcp.py +1 -1
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/agentguardproxy.egg-info/PKG-INFO +1 -1
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/pyproject.toml +1 -1
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_adapters_extended.py +11 -12
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_crewai.py +128 -78
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_langchain.py +88 -24
- agentguardproxy-0.5.0/agentguard/adapters/crewai.py +0 -596
- agentguardproxy-0.5.0/agentguard/adapters/langchain.py +0 -470
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/README.md +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/agentguard/__init__.py +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/agentguard/adapters/__init__.py +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/agentguard/adapters/browseruse.py +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/agentguardproxy.egg-info/SOURCES.txt +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/agentguardproxy.egg-info/dependency_links.txt +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/agentguardproxy.egg-info/requires.txt +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/agentguardproxy.egg-info/top_level.txt +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/setup.cfg +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_adapters.py +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_browseruse.py +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_decorator.py +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_end_to_end_real_server.py +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_guard.py +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_integration.py +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_mcp.py +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_mcp_fuzz.py +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_mcp_gateway.py +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_polling_jitter.py +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_redactor_property.py +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_sdk_integration.py +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_sdk_polish.py +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_tenant_routing.py +0 -0
- {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_wire_format.py +0 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgentGuard CrewAI Adapter (v0.5.1 hybrid: subclass + override)
|
|
3
|
+
|
|
4
|
+
Wraps CrewAI tools so every invocation passes through AgentGuard policy checks.
|
|
5
|
+
|
|
6
|
+
Why this file changed in v0.5.1
|
|
7
|
+
-------------------------------
|
|
8
|
+
v0.5.0 implemented ``GuardedCrewTool`` as a *composition* wrapper that held
|
|
9
|
+
a CrewAI ``BaseTool`` instance and registered itself as a virtual subclass
|
|
10
|
+
via ``BaseTool.register(GuardedCrewTool)``. The composition approach kept
|
|
11
|
+
the gating tight (an explicit ``__getattr__`` allowlist blocked any
|
|
12
|
+
parent-method bypass) but it broke at framework boundaries:
|
|
13
|
+
|
|
14
|
+
CrewAI 1.x + pydantic 2.12 emit::
|
|
15
|
+
|
|
16
|
+
For performance reasons, virtual subclasses registered using
|
|
17
|
+
'BaseTool.register()' are not supported in 'isinstance()' and
|
|
18
|
+
'issubclass()' checks.
|
|
19
|
+
|
|
20
|
+
Concretely::
|
|
21
|
+
|
|
22
|
+
Agent(tools=[GuardedCrewTool(...)])
|
|
23
|
+
|
|
24
|
+
raises::
|
|
25
|
+
|
|
26
|
+
pydantic_core._pydantic_core.ValidationError: 1 validation error for
|
|
27
|
+
Agent tools.0 Input should be a valid dictionary or instance of
|
|
28
|
+
BaseTool [...] input_type=GuardedCrewTool
|
|
29
|
+
|
|
30
|
+
That same architectural class affects langgraph 1.0 + langchain_core 1.x
|
|
31
|
+
(``isinstance(thing, Runnable)`` in ``coerce_to_runnable``).
|
|
32
|
+
|
|
33
|
+
The v0.5.1 fix: actually subclass CrewAI's ``BaseTool`` so isinstance
|
|
34
|
+
passes natively, and preserve the policy-enforcement contract by
|
|
35
|
+
overriding **every** entry point the framework calls. The
|
|
36
|
+
"every-method-is-on-this-class" property substitutes for the
|
|
37
|
+
composition-era ``__getattr__`` allowlist:
|
|
38
|
+
|
|
39
|
+
- ``_run`` (abstract on the parent — we MUST override) gates every
|
|
40
|
+
sync dispatch path the framework drives. CrewAI's ``BaseTool.run``,
|
|
41
|
+
``invoke`` (when present on a subclass / wrapper), and
|
|
42
|
+
``to_structured_tool`` all read ``_run`` off the instance, so a
|
|
43
|
+
gated ``_run`` covers the entire sync surface.
|
|
44
|
+
- ``_arun`` is overridden because CrewAI's ``arun`` calls ``_arun``.
|
|
45
|
+
- ``run`` / ``arun`` / ``invoke`` / ``ainvoke`` / ``__call__`` are
|
|
46
|
+
overridden explicitly so the gate fires regardless of which entry
|
|
47
|
+
the agent runtime picks. Each one also runs the tool through the
|
|
48
|
+
underlying instance's matching method, preserving CrewAI's usage
|
|
49
|
+
accounting (``_claim_usage`` etc.) when the underlying tool defines
|
|
50
|
+
those.
|
|
51
|
+
- ``to_structured_tool`` is overridden so CrewAI's
|
|
52
|
+
``to_structured_tool`` pipeline (which does ``func=self._run``) ends
|
|
53
|
+
up calling our gated ``_run``, not the wrapped tool's bare ``_run``.
|
|
54
|
+
|
|
55
|
+
The new defense contract
|
|
56
|
+
------------------------
|
|
57
|
+
Composition v0.5: "no parent methods are exposed; ``__getattr__`` is the
|
|
58
|
+
single chokepoint."
|
|
59
|
+
|
|
60
|
+
Subclass v0.5.1: "every gated method is explicitly overridden on this
|
|
61
|
+
class; the canary integration test (``test_at_real_crewai.py``) trips if
|
|
62
|
+
upstream adds a new dispatch path that bypasses our overrides." Pydantic
|
|
63
|
+
``PrivateAttr`` keeps internal references (``_tool``, ``_guard``,
|
|
64
|
+
``_scope``) off ``model_fields`` and out of ``model_dump`` payloads.
|
|
65
|
+
|
|
66
|
+
Lazy framework import
|
|
67
|
+
---------------------
|
|
68
|
+
We never import ``crewai`` at module top level — the SDK must remain
|
|
69
|
+
``pip install agentguardproxy``-friendly without the framework extra.
|
|
70
|
+
The class definition is wrapped in a builder that resolves
|
|
71
|
+
``crewai.tools.BaseTool`` on first instantiation; before that the
|
|
72
|
+
``GuardedCrewTool`` symbol is a sentinel that raises a clear
|
|
73
|
+
``ImportError`` when constructed.
|
|
74
|
+
|
|
75
|
+
Usage
|
|
76
|
+
-----
|
|
77
|
+
from agentguard.adapters.crewai import GuardedCrewTool, guard_crew_tools
|
|
78
|
+
|
|
79
|
+
guarded = GuardedCrewTool(my_tool, guard_url="http://localhost:8080")
|
|
80
|
+
|
|
81
|
+
tools = guard_crew_tools(
|
|
82
|
+
tools=[tool_a, tool_b],
|
|
83
|
+
guard_url="http://localhost:8080",
|
|
84
|
+
agent_id="my-crew-agent",
|
|
85
|
+
)
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
from __future__ import annotations
|
|
89
|
+
|
|
90
|
+
import asyncio
|
|
91
|
+
from typing import Any, List, Optional
|
|
92
|
+
|
|
93
|
+
from agentguard import (
|
|
94
|
+
DEFAULT_BASE_URL,
|
|
95
|
+
AgentGuardApprovalRequired,
|
|
96
|
+
AgentGuardDenied,
|
|
97
|
+
Guard,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# Lazy CrewAI BaseTool resolution.
|
|
103
|
+
#
|
|
104
|
+
# Importing crewai is heavy (LiteLLM, OpenAI client, etc.) and we want
|
|
105
|
+
# ``pip install agentguardproxy`` to keep working without the [crewai]
|
|
106
|
+
# extra. We resolve the BaseTool the first time a GuardedCrewTool is
|
|
107
|
+
# constructed and cache it. A mismatched / missing CrewAI raises a clear
|
|
108
|
+
# ImportError instead of a confusing AttributeError deep inside pydantic.
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
_CREWAI_BASETOOL: Optional[type] = None
|
|
112
|
+
_GUARDED_CLASS: Optional[type] = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _is_valid_args_schema(value: Any) -> bool:
|
|
116
|
+
"""Return True if ``value`` is a pydantic BaseModel subclass or a
|
|
117
|
+
JSON-schema-shaped dict. Filters MagicMock and other unusable types
|
|
118
|
+
so we never pass an invalid value into pydantic's BaseTool init."""
|
|
119
|
+
if isinstance(value, dict):
|
|
120
|
+
return True
|
|
121
|
+
try:
|
|
122
|
+
from pydantic import BaseModel # type: ignore[import-not-found]
|
|
123
|
+
return isinstance(value, type) and issubclass(value, BaseModel)
|
|
124
|
+
except Exception:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _resolve_crewai_basetool() -> type:
|
|
129
|
+
"""Return ``crewai.tools.BaseTool`` or raise a clear ImportError.
|
|
130
|
+
|
|
131
|
+
Cached so subsequent constructions do not re-walk the import paths.
|
|
132
|
+
"""
|
|
133
|
+
global _CREWAI_BASETOOL
|
|
134
|
+
if _CREWAI_BASETOOL is not None:
|
|
135
|
+
return _CREWAI_BASETOOL
|
|
136
|
+
last_err: Optional[Exception] = None
|
|
137
|
+
for module_name, attr in (
|
|
138
|
+
("crewai.tools", "BaseTool"),
|
|
139
|
+
("crewai.tools.base_tool", "BaseTool"),
|
|
140
|
+
):
|
|
141
|
+
try:
|
|
142
|
+
module = __import__(module_name, fromlist=[attr])
|
|
143
|
+
base = getattr(module, attr, None)
|
|
144
|
+
if base is not None:
|
|
145
|
+
_CREWAI_BASETOOL = base
|
|
146
|
+
return base
|
|
147
|
+
except ImportError as e:
|
|
148
|
+
last_err = e
|
|
149
|
+
continue
|
|
150
|
+
raise ImportError(
|
|
151
|
+
"agentguard CrewAI adapter requires the `crewai` package. "
|
|
152
|
+
"Install it with `pip install 'agentguardproxy[crewai]'`. "
|
|
153
|
+
f"(underlying import error: {last_err!r})"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _build_guarded_class() -> type:
|
|
158
|
+
"""Build the GuardedCrewTool class that subclasses CrewAI's BaseTool.
|
|
159
|
+
|
|
160
|
+
The class is constructed once and cached. Re-imports of this module
|
|
161
|
+
return the same class object so isinstance checks remain stable.
|
|
162
|
+
"""
|
|
163
|
+
global _GUARDED_CLASS
|
|
164
|
+
if _GUARDED_CLASS is not None:
|
|
165
|
+
return _GUARDED_CLASS
|
|
166
|
+
|
|
167
|
+
BaseTool = _resolve_crewai_basetool()
|
|
168
|
+
# pydantic v2 PrivateAttr — these fields are not part of model_fields,
|
|
169
|
+
# not validated, and not emitted by model_dump(). Imported here so the
|
|
170
|
+
# SDK does not require pydantic at module load time.
|
|
171
|
+
from pydantic import PrivateAttr # type: ignore[import-not-found]
|
|
172
|
+
|
|
173
|
+
class GuardedCrewTool(BaseTool): # type: ignore[misc, valid-type]
|
|
174
|
+
"""Hybrid subclass-and-override wrapper around a CrewAI ``BaseTool``.
|
|
175
|
+
|
|
176
|
+
Subclasses ``BaseTool`` so framework-side ``isinstance`` checks
|
|
177
|
+
succeed natively (closes the v0.5.0 pydantic-2.12 / CrewAI 1.x
|
|
178
|
+
regression). Every entry point the framework calls is explicitly
|
|
179
|
+
overridden to gate via :meth:`Guard.check` before forwarding.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
# Pydantic private attributes — held on the instance but not part
|
|
183
|
+
# of the validated model (no ``model_fields`` entry) and not
|
|
184
|
+
# emitted by ``model_dump`` / ``model_dump_json``. The ``PrivateAttr``
|
|
185
|
+
# default is set per-instance via ``model_post_init`` because the
|
|
186
|
+
# underlying tool / guard reference is not knowable at class-build
|
|
187
|
+
# time.
|
|
188
|
+
_tool: Any = PrivateAttr(default=None)
|
|
189
|
+
_guard: Any = PrivateAttr(default=None)
|
|
190
|
+
_scope: str = PrivateAttr(default="shell")
|
|
191
|
+
|
|
192
|
+
# ----------------------------------------------------------
|
|
193
|
+
# Construction
|
|
194
|
+
# ----------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def __init__( # type: ignore[no-untyped-def]
|
|
197
|
+
self,
|
|
198
|
+
tool: Any,
|
|
199
|
+
guard: Optional[Guard] = None,
|
|
200
|
+
guard_url: str = DEFAULT_BASE_URL,
|
|
201
|
+
agent_id: str = "",
|
|
202
|
+
scope: str = "shell",
|
|
203
|
+
**_extra: Any,
|
|
204
|
+
) -> None:
|
|
205
|
+
# Initialise the pydantic side from the wrapped tool's metadata.
|
|
206
|
+
# CrewAI's BaseTool requires ``name`` and ``description`` —
|
|
207
|
+
# pull them from the wrapped instance so the agent registers
|
|
208
|
+
# the tool under the right surface. ``args_schema`` is forwarded
|
|
209
|
+
# only when it is a real pydantic class / dict (filtering
|
|
210
|
+
# MagicMock and other unusable values defensively).
|
|
211
|
+
name_attr = getattr(tool, "name", type(tool).__name__)
|
|
212
|
+
desc_attr = getattr(tool, "description", "")
|
|
213
|
+
init_kwargs: dict = {
|
|
214
|
+
"name": str(name_attr) if not isinstance(name_attr, str) else name_attr,
|
|
215
|
+
"description": (
|
|
216
|
+
str(desc_attr) if not isinstance(desc_attr, str) else desc_attr
|
|
217
|
+
),
|
|
218
|
+
}
|
|
219
|
+
args_schema = getattr(tool, "args_schema", None)
|
|
220
|
+
if args_schema is not None and _is_valid_args_schema(args_schema):
|
|
221
|
+
init_kwargs["args_schema"] = args_schema
|
|
222
|
+
super().__init__(**init_kwargs)
|
|
223
|
+
# Pydantic v2 lets us set private attrs after super().__init__.
|
|
224
|
+
# Use object.__setattr__ to be defensive against any pydantic
|
|
225
|
+
# __setattr__ override that intercepts non-private fields.
|
|
226
|
+
object.__setattr__(self, "_tool", tool)
|
|
227
|
+
object.__setattr__(
|
|
228
|
+
self,
|
|
229
|
+
"_guard",
|
|
230
|
+
guard if guard is not None else Guard(guard_url, agent_id=agent_id),
|
|
231
|
+
)
|
|
232
|
+
object.__setattr__(self, "_scope", scope)
|
|
233
|
+
|
|
234
|
+
# ----------------------------------------------------------
|
|
235
|
+
# Scope inference and parameter extraction
|
|
236
|
+
# ----------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
def _infer_scope(self, tool_input: Any) -> str:
|
|
239
|
+
"""Infer scope from runtime input or static metadata."""
|
|
240
|
+
if isinstance(tool_input, dict):
|
|
241
|
+
if tool_input.get("url") or tool_input.get("domain"):
|
|
242
|
+
return "network"
|
|
243
|
+
if tool_input.get("path") or tool_input.get("file_path"):
|
|
244
|
+
return "filesystem"
|
|
245
|
+
combined = f"{self.name} {self.description}".lower()
|
|
246
|
+
if any(kw in combined for kw in ["http", "api", "fetch", "request", "url", "web"]):
|
|
247
|
+
return "network"
|
|
248
|
+
if any(kw in combined for kw in ["file", "read", "write", "directory", "path"]):
|
|
249
|
+
return "filesystem"
|
|
250
|
+
if any(kw in combined for kw in ["browser", "navigate", "click", "page"]):
|
|
251
|
+
return "browser"
|
|
252
|
+
return self._scope
|
|
253
|
+
|
|
254
|
+
def _extract_check_params(self, tool_input: Any) -> dict:
|
|
255
|
+
"""Extract check params from str/dict tool input."""
|
|
256
|
+
params: dict = {}
|
|
257
|
+
if isinstance(tool_input, str):
|
|
258
|
+
params["command"] = tool_input
|
|
259
|
+
elif isinstance(tool_input, dict):
|
|
260
|
+
if "command" in tool_input or "cmd" in tool_input:
|
|
261
|
+
params["command"] = tool_input.get(
|
|
262
|
+
"command", tool_input.get("cmd", "")
|
|
263
|
+
)
|
|
264
|
+
if "url" in tool_input:
|
|
265
|
+
params["url"] = tool_input["url"]
|
|
266
|
+
try:
|
|
267
|
+
from urllib.parse import urlparse
|
|
268
|
+
parsed = urlparse(tool_input["url"])
|
|
269
|
+
if parsed.hostname:
|
|
270
|
+
params["domain"] = parsed.hostname
|
|
271
|
+
except Exception:
|
|
272
|
+
pass
|
|
273
|
+
if "path" in tool_input or "file_path" in tool_input:
|
|
274
|
+
params["path"] = tool_input.get(
|
|
275
|
+
"path", tool_input.get("file_path", "")
|
|
276
|
+
)
|
|
277
|
+
if "session_id" in tool_input:
|
|
278
|
+
params["session_id"] = tool_input["session_id"]
|
|
279
|
+
if "est_cost" in tool_input:
|
|
280
|
+
params["est_cost"] = tool_input["est_cost"]
|
|
281
|
+
return params
|
|
282
|
+
|
|
283
|
+
# ----------------------------------------------------------
|
|
284
|
+
# The single shared gate.
|
|
285
|
+
# ----------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
def _check_or_raise(self, tool_input: Any) -> None:
|
|
288
|
+
"""Run the policy check and raise on DENY / REQUIRE_APPROVAL."""
|
|
289
|
+
params = self._extract_check_params(tool_input)
|
|
290
|
+
scope = self._infer_scope(tool_input)
|
|
291
|
+
result = self._guard.check(scope, **params)
|
|
292
|
+
|
|
293
|
+
if result.allowed:
|
|
294
|
+
return
|
|
295
|
+
if result.needs_approval:
|
|
296
|
+
raise AgentGuardApprovalRequired(
|
|
297
|
+
f"[AgentGuard] Action requires approval. "
|
|
298
|
+
f"Approve at: {result.approval_url}\n"
|
|
299
|
+
f"Reason: {result.reason}",
|
|
300
|
+
result=result,
|
|
301
|
+
)
|
|
302
|
+
raise AgentGuardDenied(
|
|
303
|
+
f"[AgentGuard] Action denied.\nReason: {result.reason}",
|
|
304
|
+
result=result,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
@staticmethod
|
|
308
|
+
def _normalize_tool_input(args: tuple, kwargs: dict) -> Any:
|
|
309
|
+
"""Map *args/**kwargs onto a single ``tool_input`` for the gate."""
|
|
310
|
+
if args:
|
|
311
|
+
return args[0] if len(args) == 1 else list(args)
|
|
312
|
+
if kwargs:
|
|
313
|
+
return dict(kwargs)
|
|
314
|
+
return ""
|
|
315
|
+
|
|
316
|
+
# ----------------------------------------------------------
|
|
317
|
+
# Synchronous entry points — every CrewAI / LangChain modern path
|
|
318
|
+
# ----------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
def _run(self, *args: Any, **kwargs: Any) -> Any:
|
|
321
|
+
"""Internal CrewAI sync entry. Implements the parent's abstract
|
|
322
|
+
``_run``. Every CrewAI sync path eventually flows through here
|
|
323
|
+
(``run`` -> ``_run``, ``invoke`` -> ``_run`` via the structured
|
|
324
|
+
tool conversion, ``to_structured_tool`` copies ``self._run`` as
|
|
325
|
+
its ``func``).
|
|
326
|
+
"""
|
|
327
|
+
tool_input = self._normalize_tool_input(args, kwargs)
|
|
328
|
+
self._check_or_raise(tool_input)
|
|
329
|
+
inner = self._tool
|
|
330
|
+
if hasattr(inner, "_run"):
|
|
331
|
+
return inner._run(*args, **kwargs)
|
|
332
|
+
if hasattr(inner, "run"):
|
|
333
|
+
return inner.run(*args, **kwargs)
|
|
334
|
+
raise AttributeError(
|
|
335
|
+
f"wrapped tool {type(inner).__name__!r} has neither _run nor run"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
def run(self, *args: Any, **kwargs: Any) -> Any:
|
|
339
|
+
"""Public sync entry. Gated.
|
|
340
|
+
|
|
341
|
+
CrewAI's BaseTool.run normally dispatches to ``_run`` after
|
|
342
|
+
``_claim_usage`` accounting; we override it so the gate fires
|
|
343
|
+
BEFORE the parent's accounting (a denied call must not consume
|
|
344
|
+
a usage slot). On ALLOW we call the wrapped tool's ``run`` so
|
|
345
|
+
its own accounting runs.
|
|
346
|
+
"""
|
|
347
|
+
tool_input = self._normalize_tool_input(args, kwargs)
|
|
348
|
+
self._check_or_raise(tool_input)
|
|
349
|
+
inner = self._tool
|
|
350
|
+
if hasattr(inner, "run"):
|
|
351
|
+
return inner.run(*args, **kwargs)
|
|
352
|
+
if hasattr(inner, "_run"):
|
|
353
|
+
return inner._run(*args, **kwargs)
|
|
354
|
+
raise AttributeError(
|
|
355
|
+
f"wrapped tool {type(inner).__name__!r} has neither run nor _run"
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def invoke( # type: ignore[override]
|
|
359
|
+
self,
|
|
360
|
+
input: Any = None, # noqa: A002 — match Runnable signature
|
|
361
|
+
config: Any = None,
|
|
362
|
+
**kwargs: Any,
|
|
363
|
+
) -> Any:
|
|
364
|
+
"""Modern Runnable entry. Gated.
|
|
365
|
+
|
|
366
|
+
CrewAI tools that don't define ``invoke`` inherit a parent
|
|
367
|
+
implementation (or none — CrewAI 1.x's BaseTool currently has
|
|
368
|
+
no ``invoke``); either way we provide a gated one. The agent
|
|
369
|
+
runtime calls ``tool.invoke(input=..., config=...)``.
|
|
370
|
+
"""
|
|
371
|
+
self._check_or_raise(input)
|
|
372
|
+
inner = self._tool
|
|
373
|
+
if hasattr(inner, "invoke"):
|
|
374
|
+
return inner.invoke(input, config=config, **kwargs)
|
|
375
|
+
if hasattr(inner, "_run"):
|
|
376
|
+
return inner._run(input, **kwargs) if not isinstance(input, dict) else inner._run(**input, **kwargs)
|
|
377
|
+
if hasattr(inner, "run"):
|
|
378
|
+
return inner.run(input, **kwargs)
|
|
379
|
+
raise AttributeError(
|
|
380
|
+
f"wrapped tool {type(inner).__name__!r} exposes no sync entry"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
384
|
+
"""Direct call (``tool(input)``). Gated."""
|
|
385
|
+
tool_input = self._normalize_tool_input(args, kwargs)
|
|
386
|
+
self._check_or_raise(tool_input)
|
|
387
|
+
inner = self._tool
|
|
388
|
+
if callable(inner):
|
|
389
|
+
try:
|
|
390
|
+
return inner(*args, **kwargs)
|
|
391
|
+
except TypeError:
|
|
392
|
+
# Pydantic BaseModel instances are technically callable
|
|
393
|
+
# in some versions but reject positional args — fall
|
|
394
|
+
# through to invoke / _run.
|
|
395
|
+
pass
|
|
396
|
+
if hasattr(inner, "invoke"):
|
|
397
|
+
return inner.invoke(args[0] if args else kwargs)
|
|
398
|
+
if hasattr(inner, "_run"):
|
|
399
|
+
return inner._run(*args, **kwargs)
|
|
400
|
+
if hasattr(inner, "run"):
|
|
401
|
+
return inner.run(*args, **kwargs)
|
|
402
|
+
raise AttributeError(
|
|
403
|
+
f"wrapped tool {type(inner).__name__!r} is not callable"
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# ----------------------------------------------------------
|
|
407
|
+
# Async entry points
|
|
408
|
+
# ----------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
async def _arun(self, *args: Any, **kwargs: Any) -> Any:
|
|
411
|
+
"""Internal async entry. Gated.
|
|
412
|
+
|
|
413
|
+
CrewAI's ``BaseTool._arun`` raises NotImplementedError unless
|
|
414
|
+
the subclass overrides it. We override here so the async path
|
|
415
|
+
is gated without forcing every wrapped tool to define _arun.
|
|
416
|
+
On ALLOW we try the wrapped tool's ``_arun`` first, then
|
|
417
|
+
``arun``, then fall through to a thread-executor sync path.
|
|
418
|
+
"""
|
|
419
|
+
tool_input = self._normalize_tool_input(args, kwargs)
|
|
420
|
+
self._check_or_raise(tool_input)
|
|
421
|
+
inner = self._tool
|
|
422
|
+
if hasattr(inner, "_arun"):
|
|
423
|
+
try:
|
|
424
|
+
return await inner._arun(*args, **kwargs)
|
|
425
|
+
except NotImplementedError:
|
|
426
|
+
pass
|
|
427
|
+
if hasattr(inner, "arun"):
|
|
428
|
+
try:
|
|
429
|
+
return await inner.arun(*args, **kwargs)
|
|
430
|
+
except NotImplementedError:
|
|
431
|
+
pass
|
|
432
|
+
loop = asyncio.get_running_loop()
|
|
433
|
+
if hasattr(inner, "_run"):
|
|
434
|
+
return await loop.run_in_executor(
|
|
435
|
+
None, lambda: inner._run(*args, **kwargs)
|
|
436
|
+
)
|
|
437
|
+
if hasattr(inner, "run"):
|
|
438
|
+
return await loop.run_in_executor(
|
|
439
|
+
None, lambda: inner.run(*args, **kwargs)
|
|
440
|
+
)
|
|
441
|
+
raise AttributeError(
|
|
442
|
+
f"wrapped tool {type(inner).__name__!r} exposes no async path"
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
async def arun(self, *args: Any, **kwargs: Any) -> Any:
|
|
446
|
+
"""Public async entry. Gated. See ``_arun`` for fallthrough notes."""
|
|
447
|
+
tool_input = self._normalize_tool_input(args, kwargs)
|
|
448
|
+
self._check_or_raise(tool_input)
|
|
449
|
+
inner = self._tool
|
|
450
|
+
if hasattr(inner, "arun"):
|
|
451
|
+
try:
|
|
452
|
+
return await inner.arun(*args, **kwargs)
|
|
453
|
+
except NotImplementedError:
|
|
454
|
+
pass
|
|
455
|
+
if hasattr(inner, "_arun"):
|
|
456
|
+
try:
|
|
457
|
+
return await inner._arun(*args, **kwargs)
|
|
458
|
+
except NotImplementedError:
|
|
459
|
+
pass
|
|
460
|
+
loop = asyncio.get_running_loop()
|
|
461
|
+
if hasattr(inner, "_run"):
|
|
462
|
+
return await loop.run_in_executor(
|
|
463
|
+
None, lambda: inner._run(*args, **kwargs)
|
|
464
|
+
)
|
|
465
|
+
if hasattr(inner, "run"):
|
|
466
|
+
return await loop.run_in_executor(
|
|
467
|
+
None, lambda: inner.run(*args, **kwargs)
|
|
468
|
+
)
|
|
469
|
+
raise AttributeError(
|
|
470
|
+
f"wrapped tool {type(inner).__name__!r} exposes no async path"
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
async def ainvoke( # type: ignore[override]
|
|
474
|
+
self,
|
|
475
|
+
input: Any = None, # noqa: A002
|
|
476
|
+
config: Any = None,
|
|
477
|
+
**kwargs: Any,
|
|
478
|
+
) -> Any:
|
|
479
|
+
"""Modern async Runnable entry. Gated."""
|
|
480
|
+
self._check_or_raise(input)
|
|
481
|
+
inner = self._tool
|
|
482
|
+
if hasattr(inner, "ainvoke"):
|
|
483
|
+
try:
|
|
484
|
+
return await inner.ainvoke(input, config=config, **kwargs)
|
|
485
|
+
except NotImplementedError:
|
|
486
|
+
pass
|
|
487
|
+
if hasattr(inner, "_arun"):
|
|
488
|
+
try:
|
|
489
|
+
return await inner._arun(input, **kwargs)
|
|
490
|
+
except NotImplementedError:
|
|
491
|
+
pass
|
|
492
|
+
if hasattr(inner, "arun"):
|
|
493
|
+
try:
|
|
494
|
+
return await inner.arun(input, **kwargs)
|
|
495
|
+
except NotImplementedError:
|
|
496
|
+
pass
|
|
497
|
+
loop = asyncio.get_running_loop()
|
|
498
|
+
if hasattr(inner, "invoke"):
|
|
499
|
+
return await loop.run_in_executor(
|
|
500
|
+
None, lambda: inner.invoke(input, config=config, **kwargs)
|
|
501
|
+
)
|
|
502
|
+
if hasattr(inner, "_run"):
|
|
503
|
+
return await loop.run_in_executor(
|
|
504
|
+
None, lambda: inner._run(input, **kwargs)
|
|
505
|
+
)
|
|
506
|
+
if hasattr(inner, "run"):
|
|
507
|
+
return await loop.run_in_executor(
|
|
508
|
+
None, lambda: inner.run(input, **kwargs)
|
|
509
|
+
)
|
|
510
|
+
raise AttributeError(
|
|
511
|
+
f"wrapped tool {type(inner).__name__!r} exposes no async path"
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
# ----------------------------------------------------------
|
|
515
|
+
# to_structured_tool override.
|
|
516
|
+
#
|
|
517
|
+
# CrewAI's BaseTool.to_structured_tool builds a CrewStructuredTool
|
|
518
|
+
# with ``func=self._run``. Because our _run is gated, the resulting
|
|
519
|
+
# CrewStructuredTool also calls into the gate when invoked. We
|
|
520
|
+
# override so the structured tool gets our gated _run, not the
|
|
521
|
+
# wrapped tool's bare _run.
|
|
522
|
+
# ----------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
def to_structured_tool(self) -> Any:
|
|
525
|
+
"""Convert to a CrewStructuredTool whose ``func`` is our
|
|
526
|
+
gated ``_run``. Override of the parent so structured-tool
|
|
527
|
+
conversion does not bypass the gate.
|
|
528
|
+
"""
|
|
529
|
+
from crewai.tools.structured_tool import CrewStructuredTool # type: ignore
|
|
530
|
+
|
|
531
|
+
self._set_args_schema()
|
|
532
|
+
structured_tool = CrewStructuredTool(
|
|
533
|
+
name=self.name,
|
|
534
|
+
description=self.description,
|
|
535
|
+
args_schema=self.args_schema,
|
|
536
|
+
func=self._run, # gated
|
|
537
|
+
result_as_answer=self.result_as_answer,
|
|
538
|
+
max_usage_count=self.max_usage_count,
|
|
539
|
+
current_usage_count=self.current_usage_count,
|
|
540
|
+
cache_function=self.cache_function,
|
|
541
|
+
)
|
|
542
|
+
structured_tool._original_tool = self
|
|
543
|
+
return structured_tool
|
|
544
|
+
|
|
545
|
+
# ----------------------------------------------------------
|
|
546
|
+
# model_dump / model_dump_json: hide private gating fields.
|
|
547
|
+
#
|
|
548
|
+
# PrivateAttr fields are not in model_fields by default, so they
|
|
549
|
+
# are already excluded from model_dump output. We override anyway
|
|
550
|
+
# to be explicit and to filter any future audit-tooling that might
|
|
551
|
+
# walk __dict__.
|
|
552
|
+
# ----------------------------------------------------------
|
|
553
|
+
|
|
554
|
+
def model_dump(self, *args: Any, **kwargs: Any) -> dict: # type: ignore[override]
|
|
555
|
+
data = super().model_dump(*args, **kwargs)
|
|
556
|
+
# Defensive: strip our private attrs if pydantic ever starts
|
|
557
|
+
# leaking them.
|
|
558
|
+
for k in ("_tool", "_guard", "_scope"):
|
|
559
|
+
data.pop(k, None)
|
|
560
|
+
return data
|
|
561
|
+
|
|
562
|
+
_GUARDED_CLASS = GuardedCrewTool
|
|
563
|
+
return GuardedCrewTool
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
# ---------------------------------------------------------------------------
|
|
567
|
+
# Module-level GuardedCrewTool symbol.
|
|
568
|
+
#
|
|
569
|
+
# We expose a callable factory so `from agentguard.adapters.crewai import
|
|
570
|
+
# GuardedCrewTool; GuardedCrewTool(my_tool)` works without a top-level
|
|
571
|
+
# crewai import. The factory builds (and caches) the real class on first
|
|
572
|
+
# call and constructs an instance.
|
|
573
|
+
# ---------------------------------------------------------------------------
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
class _GuardedCrewToolFactory:
|
|
577
|
+
"""Factory whose ``__call__`` returns a GuardedCrewTool instance.
|
|
578
|
+
|
|
579
|
+
Behaves like a class for ``isinstance`` and ``issubclass`` checks via
|
|
580
|
+
delegation to the real class once it is built. This means user code
|
|
581
|
+
that does::
|
|
582
|
+
|
|
583
|
+
isinstance(t, GuardedCrewTool)
|
|
584
|
+
|
|
585
|
+
works regardless of whether the underlying class has been built yet —
|
|
586
|
+
the first construction triggers the build, and the assertion uses the
|
|
587
|
+
real class going forward.
|
|
588
|
+
|
|
589
|
+
Module-level constant naming convention: this factory IS the public
|
|
590
|
+
``GuardedCrewTool`` symbol. The real class object is reachable via
|
|
591
|
+
:func:`_build_guarded_class`.
|
|
592
|
+
"""
|
|
593
|
+
|
|
594
|
+
def __call__(self, tool: Any, *args: Any, **kwargs: Any) -> Any:
|
|
595
|
+
cls = _build_guarded_class()
|
|
596
|
+
return cls(tool, *args, **kwargs)
|
|
597
|
+
|
|
598
|
+
def __instancecheck__(self, obj: Any) -> bool: # pragma: no cover - delegated
|
|
599
|
+
cls = _GUARDED_CLASS
|
|
600
|
+
if cls is None:
|
|
601
|
+
return False
|
|
602
|
+
return isinstance(obj, cls)
|
|
603
|
+
|
|
604
|
+
def __subclasscheck__(self, sub: Any) -> bool: # pragma: no cover - delegated
|
|
605
|
+
cls = _GUARDED_CLASS
|
|
606
|
+
if cls is None:
|
|
607
|
+
return False
|
|
608
|
+
return issubclass(sub, cls)
|
|
609
|
+
|
|
610
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
611
|
+
return "<GuardedCrewTool factory (lazy crewai.BaseTool subclass)>"
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
GuardedCrewTool = _GuardedCrewToolFactory()
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def guard_crew_tools(
|
|
618
|
+
tools: List[Any],
|
|
619
|
+
guard_url: str = DEFAULT_BASE_URL,
|
|
620
|
+
agent_id: str = "",
|
|
621
|
+
default_scope: str = "shell",
|
|
622
|
+
) -> List[Any]:
|
|
623
|
+
"""Wrap a list of CrewAI tools with AgentGuard enforcement.
|
|
624
|
+
|
|
625
|
+
Args:
|
|
626
|
+
tools: List of CrewAI tools to guard.
|
|
627
|
+
guard_url: URL of the AgentGuard proxy.
|
|
628
|
+
agent_id: Identifier for this agent in audit logs.
|
|
629
|
+
default_scope: Default policy scope.
|
|
630
|
+
|
|
631
|
+
Returns:
|
|
632
|
+
List of guarded tool instances. Each instance shares a single
|
|
633
|
+
:class:`Guard` so they batch under one agent_id.
|
|
634
|
+
"""
|
|
635
|
+
cls = _build_guarded_class()
|
|
636
|
+
guard = Guard(guard_url, agent_id=agent_id)
|
|
637
|
+
return [cls(tool, guard=guard, scope=default_scope) for tool in tools]
|