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.
Files changed (35) hide show
  1. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/PKG-INFO +1 -1
  2. agentguardproxy-0.5.1/agentguard/adapters/crewai.py +637 -0
  3. agentguardproxy-0.5.1/agentguard/adapters/langchain.py +635 -0
  4. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/agentguard/adapters/mcp.py +1 -1
  5. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/agentguardproxy.egg-info/PKG-INFO +1 -1
  6. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/pyproject.toml +1 -1
  7. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_adapters_extended.py +11 -12
  8. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_crewai.py +128 -78
  9. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_langchain.py +88 -24
  10. agentguardproxy-0.5.0/agentguard/adapters/crewai.py +0 -596
  11. agentguardproxy-0.5.0/agentguard/adapters/langchain.py +0 -470
  12. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/README.md +0 -0
  13. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/agentguard/__init__.py +0 -0
  14. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/agentguard/adapters/__init__.py +0 -0
  15. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/agentguard/adapters/browseruse.py +0 -0
  16. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/agentguardproxy.egg-info/SOURCES.txt +0 -0
  17. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/agentguardproxy.egg-info/dependency_links.txt +0 -0
  18. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/agentguardproxy.egg-info/requires.txt +0 -0
  19. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/agentguardproxy.egg-info/top_level.txt +0 -0
  20. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/setup.cfg +0 -0
  21. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_adapters.py +0 -0
  22. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_browseruse.py +0 -0
  23. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_decorator.py +0 -0
  24. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_end_to_end_real_server.py +0 -0
  25. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_guard.py +0 -0
  26. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_integration.py +0 -0
  27. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_mcp.py +0 -0
  28. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_mcp_fuzz.py +0 -0
  29. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_mcp_gateway.py +0 -0
  30. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_polling_jitter.py +0 -0
  31. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_redactor_property.py +0 -0
  32. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_sdk_integration.py +0 -0
  33. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_sdk_polish.py +0 -0
  34. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_tenant_routing.py +0 -0
  35. {agentguardproxy-0.5.0 → agentguardproxy-0.5.1}/tests/test_wire_format.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentguardproxy
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: Python SDK for AgentGuard — the firewall for AI agents
5
5
  Author-email: Cauã Ferraz <cauaferrazp@gmail.com>
6
6
  License-Expression: Apache-2.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]