agentguardproxy 0.5.1__tar.gz → 0.5.2__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 (33) hide show
  1. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/PKG-INFO +2 -3
  2. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/agentguard/__init__.py +61 -64
  3. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/agentguard/adapters/browseruse.py +14 -15
  4. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/agentguard/adapters/crewai.py +31 -58
  5. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/agentguard/adapters/langchain.py +30 -46
  6. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/agentguard/adapters/mcp.py +33 -35
  7. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/agentguardproxy.egg-info/PKG-INFO +2 -3
  8. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/pyproject.toml +8 -9
  9. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/tests/test_adapters_extended.py +2 -2
  10. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/tests/test_crewai.py +2 -2
  11. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/tests/test_guard.py +1 -1
  12. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/tests/test_langchain.py +10 -3
  13. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/README.md +0 -0
  14. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/agentguard/adapters/__init__.py +0 -0
  15. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/agentguardproxy.egg-info/SOURCES.txt +0 -0
  16. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/agentguardproxy.egg-info/dependency_links.txt +0 -0
  17. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/agentguardproxy.egg-info/requires.txt +0 -0
  18. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/agentguardproxy.egg-info/top_level.txt +0 -0
  19. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/setup.cfg +0 -0
  20. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/tests/test_adapters.py +0 -0
  21. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/tests/test_browseruse.py +0 -0
  22. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/tests/test_decorator.py +0 -0
  23. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/tests/test_end_to_end_real_server.py +0 -0
  24. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/tests/test_integration.py +0 -0
  25. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/tests/test_mcp.py +0 -0
  26. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/tests/test_mcp_fuzz.py +0 -0
  27. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/tests/test_mcp_gateway.py +0 -0
  28. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/tests/test_polling_jitter.py +0 -0
  29. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/tests/test_redactor_property.py +0 -0
  30. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/tests/test_sdk_integration.py +0 -0
  31. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/tests/test_sdk_polish.py +0 -0
  32. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/tests/test_tenant_routing.py +0 -0
  33. {agentguardproxy-0.5.1 → agentguardproxy-0.5.2}/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.1
3
+ Version: 0.5.2
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
@@ -12,14 +12,13 @@ Keywords: ai,agents,firewall,policy,guardrails,safety
12
12
  Classifier: Development Status :: 3 - Alpha
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: Programming Language :: Python :: 3
15
- Classifier: Programming Language :: Python :: 3.9
16
15
  Classifier: Programming Language :: Python :: 3.10
17
16
  Classifier: Programming Language :: Python :: 3.11
18
17
  Classifier: Programming Language :: Python :: 3.12
19
18
  Classifier: Programming Language :: Python :: 3.13
20
19
  Classifier: Topic :: Security
21
20
  Classifier: Topic :: Software Development :: Libraries
22
- Requires-Python: >=3.9
21
+ Requires-Python: >=3.10
23
22
  Description-Content-Type: text/markdown
24
23
  Provides-Extra: langchain
25
24
  Requires-Dist: langchain-core<2.0,>=0.3; extra == "langchain"
@@ -47,7 +47,7 @@ DECISION_REQUIRE_APPROVAL = "REQUIRE_APPROVAL"
47
47
 
48
48
  # API endpoint paths. The leading "/v1" is added by Guard._url so a single
49
49
  # code path handles both the legacy /v1/<suffix> URLs and the tenant-aware
50
- # /v1/t/<tenant>/<suffix> form introduced in v0.5 (worker A7).
50
+ # /v1/t/<tenant>/<suffix> form.
51
51
  ENDPOINT_CHECK = "/check"
52
52
  ENDPOINT_APPROVE = "/approve/"
53
53
  ENDPOINT_DENY = "/deny/"
@@ -61,10 +61,10 @@ ENDPOINT_STATUS = "/status/"
61
61
  LOCAL_TENANT_ID = "local"
62
62
 
63
63
  # Fail-mode values for the Guard() constructor. "deny" fails closed when the
64
- # AgentGuard proxy is unreachable (the v0.4.0 default and current v0.4.1
65
- # default). "allow" fails open — permitted as an explicit opt-in for agents
66
- # whose threat model treats AgentGuard as best-effort; the caller is
67
- # responsible for any resulting safety implications.
64
+ # AgentGuard proxy is unreachable (the default). "allow" fails open
65
+ # permitted as an explicit opt-in for agents whose threat model treats
66
+ # AgentGuard as best-effort; the caller is responsible for any resulting
67
+ # safety implications.
68
68
  FAIL_MODE_DENY = "deny"
69
69
  FAIL_MODE_ALLOW = "allow"
70
70
  _VALID_FAIL_MODES = (FAIL_MODE_DENY, FAIL_MODE_ALLOW)
@@ -95,16 +95,16 @@ class CheckResult:
95
95
  # --- Typed exceptions raised by the @guarded decorator ---
96
96
  #
97
97
  # All three extend PermissionError so callers that already catch
98
- # PermissionError (the v0.4.0/v0.4.1 behavior) keep working unchanged.
99
- # New callers can catch the specific subclass and read structured fields
100
- # (result, approval_id, approval_url) instead of parsing error strings.
98
+ # PermissionError keep working. New callers can catch the specific
99
+ # subclass and read structured fields (result, approval_id,
100
+ # approval_url) instead of parsing error strings.
101
101
 
102
102
  class AgentGuardError(PermissionError):
103
103
  """Base class for AgentGuard-raised permission failures.
104
104
 
105
105
  Carries the originating :class:`CheckResult` (if any) so callers can
106
106
  inspect the decision, matched rule, and approval metadata without
107
- re-running the check. Messages preserve the v0.4.1 string format so
107
+ re-running the check. Messages preserve a stable string format so
108
108
  existing regex/text matchers continue to work.
109
109
  """
110
110
 
@@ -156,8 +156,8 @@ class AgentGuardAuthError(AgentGuardError):
156
156
  ``/v1/audit``).
157
157
 
158
158
  Distinguishes "API key wrong / expired" from "approval poll timed
159
- out" so callers can surface the right operator-facing error. v0.5
160
- addition (R5 P9). Extends :class:`AgentGuardError` so existing
159
+ out" so callers can surface the right operator-facing error.
160
+ Extends :class:`AgentGuardError` so existing
161
161
  ``except PermissionError:`` handlers still catch it.
162
162
  """
163
163
 
@@ -171,15 +171,21 @@ class AgentGuardAuthError(AgentGuardError):
171
171
  self.status = status
172
172
 
173
173
 
174
- # AgentGuardTimeoutError is an alias for AgentGuardApprovalTimeout — the
175
- # v0.5 plan referred to it by that name, but the v0.4.1 SDK already shipped
176
- # AgentGuardApprovalTimeout. The alias gives the new name without breaking
177
- # downstream `except AgentGuardApprovalTimeout:` handlers.
174
+ # AgentGuardTimeoutError is an alias for AgentGuardApprovalTimeout
175
+ # kept for backwards compatibility `except AgentGuardApprovalTimeout:`
176
+ # handlers still match.
178
177
  AgentGuardTimeoutError = AgentGuardApprovalTimeout
179
178
 
180
179
 
181
180
  class Guard:
182
- """Client for the AgentGuard proxy."""
181
+ """Client for the AgentGuard server — the SDK enforcement layer.
182
+
183
+ Call ``guard.check(scope, ...)`` before every gated action, or use the
184
+ ``@guarded`` decorator / framework adapters in ``agentguard.adapters``
185
+ for higher-level integration. For wire-level enforcement that needs no
186
+ agent code change, see the ``agentguard-mcp-gateway`` and
187
+ ``agentguard-llm-proxy`` binaries.
188
+ """
183
189
 
184
190
  def __init__(
185
191
  self,
@@ -199,26 +205,20 @@ class Guard:
199
205
  timeout: HTTP timeout in seconds for individual calls.
200
206
  api_key: Bearer token for /v1/approve, /v1/deny, /v1/status.
201
207
  Falls back to AGENTGUARD_API_KEY.
202
- fail_mode: Behavior when the proxy is unreachable. "deny" (the
203
- v0.4.0 default, current v0.4.1 default) returns a DENY
204
- result so the agent fails closed. "allow" returns an ALLOW
205
- result — use only when the threat model treats AgentGuard
206
- as best-effort and the caller accepts the safety trade-off.
207
- An invalid value raises ValueError at construction so the
208
- bug surfaces at startup instead of mid-request.
209
- tenant_id: Optional tenant identifier (v0.5+). When set to a
208
+ fail_mode: Behavior when the proxy is unreachable. "deny"
209
+ (the default) returns a DENY result so the agent fails
210
+ closed. "allow" returns an ALLOW result — use only when
211
+ the threat model treats AgentGuard as best-effort and
212
+ the caller accepts the safety trade-off. An invalid
213
+ value raises ValueError at construction.
214
+ tenant_id: Optional tenant identifier. When set to a
210
215
  non-empty value other than ``"local"``, every HTTP call is
211
216
  routed through the tenant-aware ``/v1/t/{tenant_id}/...``
212
- URL family instead of the legacy ``/v1/...`` path. ``None``
213
- or ``"local"`` selects the legacy URLs and is wire-compatible
214
- with v0.4.x servers. Falls back to ``AGENTGUARD_TENANT_ID``.
215
- v0.5 servers only recognise the literal ``"local"`` tenant;
216
- v0.6 will validate against a configured tenant registry.
217
-
218
- v0.5.0 parity note: the TypeScript SDK already honors `failMode`,
219
- default `"deny"`. v0.5.0 will align both SDKs on explicit fail-mode
220
- documentation. Adding `fail_mode` now is purely forward-compatible;
221
- omitting it preserves v0.4.0 semantics exactly.
217
+ URL family instead of the legacy ``/v1/...`` path.
218
+ ``None`` or ``"local"`` selects the legacy URLs. Falls
219
+ back to ``AGENTGUARD_TENANT_ID``. The bundled
220
+ FilePolicyProvider only recognises ``"local"``;
221
+ multi-tenant providers can register others.
222
222
  """
223
223
  if fail_mode not in _VALID_FAIL_MODES:
224
224
  raise ValueError(
@@ -314,13 +314,12 @@ class Guard:
314
314
 
315
315
  try:
316
316
  with request.urlopen(req, timeout=self.timeout) as resp:
317
- # --- Honest response validation (R5 E8 / S13) ---
318
- # The previous implementation decoded whatever the proxy
319
- # returned. A misconfigured reverse proxy serving an HTML
320
- # error page or a chunked plaintext body would silently
321
- # produce a JSONDecodeError that we *did* catch but
322
- # 200-OK plus a non-JSON body that happened to start with
323
- # `{` would slip through. We now positively assert:
317
+ # --- Honest response validation ---
318
+ # A misconfigured reverse proxy serving an HTML error
319
+ # page or a chunked plaintext body would silently produce
320
+ # a JSONDecodeError that we *do* catch but 200-OK plus
321
+ # a non-JSON body that happened to start with `{` would
322
+ # slip through. We positively assert:
324
323
  # 1. status in 2xx
325
324
  # 2. Content-Type is application/json (charset suffix ok)
326
325
  # 3. body is a dict carrying a `decision` field
@@ -386,9 +385,9 @@ class Guard:
386
385
  approval_url=body.get("approval_url", ""),
387
386
  )
388
387
  except (error.URLError, OSError, json.JSONDecodeError) as e:
389
- # Transport failure. fail_mode picks the safe default: "deny"
390
- # preserves v0.4.0 fail-closed semantics; "allow" is an opt-in
391
- # for callers whose threat model treats AgentGuard as advisory.
388
+ # Transport failure. fail_mode picks the default: "deny"
389
+ # fails closed; "allow" is an opt-in for callers whose
390
+ # threat model treats AgentGuard as advisory.
392
391
  #
393
392
  # We catch three classes here:
394
393
  # - URLError: urlopen() connection-phase failures (connect
@@ -411,7 +410,7 @@ class Guard:
411
410
 
412
411
  Centralizes the fail-mode dispatch so transport failures and HTTP
413
412
  contract violations (bad status, wrong content type, malformed
414
- body) all flow through one decision point. v0.5 addition (R5 E8).
413
+ body) all flow through one decision point.
415
414
  """
416
415
  decision = DECISION_ALLOW if self.fail_mode == FAIL_MODE_ALLOW else DECISION_DENY
417
416
  return CheckResult(decision=decision, reason=reason)
@@ -459,19 +458,17 @@ class Guard:
459
458
  Sends the API key on every poll because /v1/status is now auth-gated
460
459
  on servers configured with --api-key.
461
460
 
462
- v0.5 changes (R5 E14, P9):
461
+ Behavior:
463
462
  - Polling is jittered to ``[0.8, 1.2] * poll_interval`` to avoid
464
463
  synchronized retries from many clients waiting on the same
465
464
  approval bursting the proxy at exact ``poll_interval`` boundaries.
466
- ``time.sleep`` is retained — it is the documented pattern for
467
- this kind of polling loop and consistent with the v0.4.x SDK.
468
465
  - HTTP 401 / 403 from the status endpoint raise
469
466
  :class:`AgentGuardAuthError` immediately. Continuing to poll
470
467
  would just spin until ``timeout`` elapsed and return a
471
468
  synthetic "Approval timed out" DENY, masking the real cause
472
- (wrong/expired API key). Other HTTPErrors and URLErrors keep
473
- the v0.4.x behavior of swallowing-and-retrying so transient
474
- network blips do not abort a long-running approval wait.
469
+ (wrong/expired API key). Other HTTPErrors and URLErrors are
470
+ swallowed and retried so transient network blips do not abort
471
+ a long-running approval wait.
475
472
  """
476
473
  deadline = time.time() + timeout
477
474
  while time.time() < deadline:
@@ -519,9 +516,9 @@ class Guard:
519
516
  # ``Guard.check``. Anything outside this set is almost always a typo
520
517
  # (e.g. ``agent="x"`` instead of ``meta={"agent":"x"}``) — and silently
521
518
  # forwarding it to ``Guard.check`` would either be dropped on the floor
522
- # (``check`` only inspects keyword args it knows about) or raise a confusing
523
- # TypeError much later in the call chain. v0.5 R5 E15 closes this footgun
524
- # at the decorator boundary.
519
+ # (``check`` only inspects keyword args it knows about) or raise a
520
+ # confusing TypeError much later in the call chain. Reject at the
521
+ # decorator boundary so the typo surfaces at definition time.
525
522
  _GUARDED_VALID_CHECK_KWARGS = frozenset({
526
523
  "action",
527
524
  "command",
@@ -570,17 +567,16 @@ def guarded(
570
567
  decision: ALLOW runs the function, DENY raises :class:`AgentGuardDenied`,
571
568
  a synthetic "Approval timed out" raises :class:`AgentGuardApprovalTimeout`.
572
569
  With ``wait_for_approval=False`` (default), the wrapper raises
573
- :class:`AgentGuardApprovalRequired` immediately preserving v0.4.1
574
- behavior exactly, because that class extends ``PermissionError``.
575
-
576
- All raised exceptions extend :class:`PermissionError`, so existing
577
- ``except PermissionError:`` handlers continue to work unchanged.
570
+ :class:`AgentGuardApprovalRequired` immediately. That class extends
571
+ ``PermissionError``, so existing ``except PermissionError:`` handlers
572
+ continue to work unchanged.
578
573
 
579
- v0.5 (R5 E15): unknown ``**check_kwargs`` raise ``TypeError`` at
580
- decoration time. Only the keyword arguments :meth:`Guard.check`
581
- understands (``action``, ``command``, ``path``, ``domain``, ``url``,
574
+ Unknown ``**check_kwargs`` raise ``TypeError`` at decoration time.
575
+ Only the keyword arguments :meth:`Guard.check` understands
576
+ (``action``, ``command``, ``path``, ``domain``, ``url``,
582
577
  ``session_id``, ``est_cost``, ``meta``) are accepted; a typo like
583
- ``agent="x"`` no longer silently disappears.
578
+ ``agent="x"`` is rejected at definition time rather than silently
579
+ dropped.
584
580
  """
585
581
  unknown = set(check_kwargs) - _GUARDED_VALID_CHECK_KWARGS
586
582
  if unknown:
@@ -623,7 +619,8 @@ def guarded(
623
619
  result=resolved,
624
620
  )
625
621
  raise AgentGuardApprovalRequired(
626
- # Preserve v0.4.1 message text so text-matchers still hit.
622
+ # Stable message text text-matchers in caller code
623
+ # depend on it.
627
624
  f"Action requires approval. Approve at: {result.approval_url}",
628
625
  result=result,
629
626
  approval_id=result.approval_id,
@@ -34,9 +34,10 @@ Design notes
34
34
  the methods listed in :data:`_GATED_METHODS` plus a small allowlist of
35
35
  read-only properties via :data:`_ALLOWED_PASSTHROUGH`.
36
36
  - **Default deny on attribute access.** Anything not on the allowlist
37
- raises ``AttributeError`` with a security explanation. This catches the
38
- v0.4.x bypass where ``__getattr__`` silently fell through to
39
- ``self._page.click(...)``, leaving every action method un-gated.
37
+ raises ``AttributeError`` with a security explanation. A permissive
38
+ ``__getattr__`` would silently fall through to
39
+ ``self._page.click(...)`` and leave every action method un-gated, so
40
+ the wrapper never adds one.
40
41
  - **Form values are redacted before transmission.** ``check_form_input``
41
42
  passes the field NAME through (operators need it for audit context)
42
43
  but rewrites long values to ``<redacted; len=N>`` so audit logs do not
@@ -44,9 +45,8 @@ Design notes
44
45
  through :func:`agentguard.adapters.mcp._redact` for the standard secret
45
46
  patterns (Bearer tokens, AWS keys, ``secret=...`` pairs, etc.).
46
47
 
47
- Closes audit findings R5 E4 (modern-API bypass via __getattr__),
48
- R5 E5 / R7 E3 (data scope unhandled by the engine — see
49
- pkg/policy/engine_data_test.go for the engine-side close).
48
+ The data-scope contract is pinned by pkg/policy/engine_data_test.go
49
+ on the engine side.
50
50
  """
51
51
 
52
52
  from typing import Any, Optional
@@ -215,11 +215,10 @@ class GuardedPage:
215
215
 
216
216
  Gated methods are explicit attributes on this class (see the bodies
217
217
  below). Anything else is rejected via ``__getattr__`` against
218
- :data:`_ALLOWED_PASSTHROUGH`. This is deliberately stricter than the
219
- v0.4.x adapter, which proxied every attribute access that proxying
220
- was the bypass closed by audit finding R5 E4: ``page.click(...)``,
221
- ``page.fill(...)``, ``page.evaluate(...)`` all flowed through to the
222
- raw Page without ever consulting the policy.
218
+ :data:`_ALLOWED_PASSTHROUGH`. A permissive ``__getattr__`` that
219
+ proxied every attribute access would let ``page.click(...)``,
220
+ ``page.fill(...)``, ``page.evaluate(...)`` flow through to the raw
221
+ Page without ever consulting the policy — so we never add one.
223
222
 
224
223
  Read-only properties listed in :data:`_ALLOWED_PASSTHROUGH` (url,
225
224
  title, content, viewport_size, is_closed, main_frame, context) are
@@ -529,10 +528,10 @@ class GuardedPage:
529
528
 
530
529
  Anything else raises ``AttributeError`` with a security
531
530
  explanation. The default-deny posture is intentional: silently
532
- proxying unknown attributes to ``self._page`` was the v0.4.x
533
- bypass that let modern Playwright APIs (route, expose_function,
534
- ...) skip every gate. Adding a new method to the gated surface
535
- is preferable to widening the allowlist.
531
+ proxying unknown attributes to ``self._page`` would let modern
532
+ Playwright APIs (route, expose_function, ...) skip every gate.
533
+ Adding a new method to the gated surface is preferable to
534
+ widening the allowlist.
536
535
  """
537
536
  # Avoid infinite recursion when our own internals are accessed
538
537
  # before __init__ completes.
@@ -1,67 +1,39 @@
1
1
  """
2
- AgentGuard CrewAI Adapter (v0.5.1 hybrid: subclass + override)
2
+ AgentGuard CrewAI Adapter subclass + override.
3
3
 
4
4
  Wraps CrewAI tools so every invocation passes through AgentGuard policy checks.
5
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.
6
+ Architecture
7
+ ------------
8
+ ``GuardedCrewTool`` subclasses CrewAI's ``BaseTool`` directly so
9
+ ``isinstance(thing, BaseTool)`` checks succeed natively (CrewAI 1.x +
10
+ pydantic 2.12 reject virtual-subclass registration via
11
+ ``BaseTool.register``). To keep the gate tight without an
12
+ ``__getattr__`` allowlist, **every** entry point the framework calls is
13
+ explicitly overridden:
14
+
15
+ - ``_run`` (abstract on the parent) gates every sync dispatch path
16
+ CrewAI drives. CrewAI's ``BaseTool.run``, ``invoke`` (when present
17
+ on a subclass / wrapper), and ``to_structured_tool`` all read
18
+ ``_run`` off the instance, so a gated ``_run`` covers the entire
19
+ sync surface.
44
20
  - ``_arun`` is overridden because CrewAI's ``arun`` calls ``_arun``.
45
21
  - ``run`` / ``arun`` / ``invoke`` / ``ainvoke`` / ``__call__`` are
46
22
  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.
23
+ the agent runtime picks. Each one runs the tool through the
24
+ underlying instance's matching method to preserve CrewAI's usage
25
+ accounting (``_claim_usage`` etc.).
51
26
  - ``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."
27
+ ``to_structured_tool`` pipeline (which does ``func=self._run``)
28
+ ends up calling our gated ``_run``, not the wrapped tool's bare
29
+ ``_run``.
59
30
 
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.
31
+ The defense contract: every gated method is explicitly overridden on
32
+ this class; the canary integration test (``test_at_real_crewai.py``)
33
+ trips if upstream adds a new dispatch path that bypasses our overrides.
34
+ Pydantic ``PrivateAttr`` keeps internal references (``_tool``,
35
+ ``_guard``, ``_scope``) off ``model_fields`` and out of ``model_dump``
36
+ payloads.
65
37
 
66
38
  Lazy framework import
67
39
  ---------------------
@@ -174,9 +146,10 @@ def _build_guarded_class() -> type:
174
146
  """Hybrid subclass-and-override wrapper around a CrewAI ``BaseTool``.
175
147
 
176
148
  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.
149
+ succeed natively (pydantic 2.12 / CrewAI 1.x reject virtual-
150
+ subclass registration). Every entry point the framework calls
151
+ is explicitly overridden to gate via :meth:`Guard.check` before
152
+ forwarding.
180
153
  """
181
154
 
182
155
  # Pydantic private attributes — held on the instance but not part
@@ -1,31 +1,20 @@
1
1
  """
2
- AgentGuard LangChain Adapter (v0.5.1 hybrid: subclass + override)
2
+ AgentGuard LangChain Adapter subclass + override.
3
3
 
4
4
  Wraps LangChain tools so every invocation passes through AgentGuard policy
5
5
  checks.
6
6
 
7
- Why this file changed in v0.5.1
8
- -------------------------------
9
- v0.5.0 implemented ``GuardedTool`` as a *composition* wrapper (the wrapper
10
- held a wrapped LangChain tool via ``self.__tool`` and forbade attribute
11
- access through a strict ``__getattr__`` allowlist). That kept the gate
12
- tight but broke at framework boundaries:
13
-
14
- * langgraph 1.0 + langchain_core 1.x's ``coerce_to_runnable`` runs
15
- ``isinstance(thing, Runnable)`` and rejects composition wrappers.
16
- * langchain 1.x's ``langchain.agents.create_agent`` (the successor to
17
- the deprecated ``create_react_agent``) uses the same isinstance
18
- check. The integration test had to wrap GuardedTool in a
19
- ``Tool.from_function(func=lambda x: gt.invoke(x))`` to register it
20
- with the agent — that workaround is no longer required after v0.5.1.
21
-
22
- The v0.5.1 fix: subclass ``langchain_core.tools.BaseTool`` so isinstance
23
- passes natively, and preserve the policy-enforcement contract by
24
- overriding **every** entry point the framework calls. The
25
- "every-method-is-on-this-class" property substitutes for the
26
- composition-era ``__getattr__`` allowlist.
27
-
28
- Gated methods (each calls ``Guard.check`` before forwarding):
7
+ Architecture
8
+ ------------
9
+ ``GuardedTool`` subclasses ``langchain_core.tools.BaseTool`` directly
10
+ so ``isinstance(thing, Runnable)`` and ``isinstance(thing, BaseTool)``
11
+ checks succeed natively. langgraph 1.0 + langchain_core 1.x's
12
+ ``coerce_to_runnable`` and langchain 1.x's
13
+ ``langchain.agents.create_agent`` both reject composition wrappers,
14
+ so the subclass approach is the only one that registers cleanly.
15
+
16
+ To keep the gate tight without an ``__getattr__`` allowlist, **every**
17
+ entry point the framework calls is explicitly overridden:
29
18
 
30
19
  - ``_run`` (abstract on the parent — required by the subclass) — the
31
20
  canonical sync dispatch path; ``BaseTool.run`` calls into ``_run``
@@ -39,19 +28,14 @@ Gated methods (each calls ``Guard.check`` before forwarding):
39
28
  gating is a v0.6 issue.
40
29
  - ``batch`` / ``abatch`` — gate each input independently; first DENY
41
30
  raises for the whole batch (whole-batch-fails-on-first-deny).
42
- - ``run`` / ``arun`` — legacy entries; preserve v0.4.x string-on-deny
43
- semantics (kept for backward compat).
44
-
45
- The new defense contract
46
- ------------------------
47
- Composition v0.5.0: "no parent methods are exposed; ``__getattr__`` is
48
- the single chokepoint."
31
+ - ``run`` / ``arun`` — legacy entries; preserve string-on-deny
32
+ semantics for callers that depend on it.
49
33
 
50
- Subclass v0.5.1: "every gated method is explicitly overridden on this
51
- class. Pydantic ``PrivateAttr`` keeps internal references off
34
+ The defense contract: every gated method is explicitly overridden on
35
+ this class. Pydantic ``PrivateAttr`` keeps internal references off
52
36
  ``model_fields`` and out of ``model_dump`` payloads. The canary
53
- integration test (``test_at_real_langchain.py``) trips if upstream adds
54
- a new dispatch path that bypasses our overrides."
37
+ integration test (``test_at_real_langchain.py``) trips if upstream
38
+ adds a new dispatch path that bypasses our overrides.
55
39
 
56
40
  Lazy framework import
57
41
  ---------------------
@@ -65,10 +49,9 @@ the ``GuardedTool`` symbol is a callable factory that raises a clear
65
49
  Stream gating limitation
66
50
  ========================
67
51
 
68
- For ``stream``/``astream`` the gate fires **once** at stream open.
69
- Mid-stream tool calls (rare in v0.4 LangChain, increasingly common in
70
- agent loops with chunk-driven side-effects) bypass the gate. v0.6 issue
71
- title: ``langchain: per-chunk policy gating in stream()/astream()``.
52
+ For ``stream`` / ``astream`` the gate fires **once** at stream open.
53
+ Mid-stream tool calls bypass the gate. TODO(v0.6): per-chunk gating
54
+ in ``stream()`` / ``astream()``.
72
55
  """
73
56
 
74
57
  from __future__ import annotations
@@ -143,10 +126,10 @@ def _build_guarded_tool_class() -> type:
143
126
  """Hybrid subclass-and-override wrapper around a LangChain ``BaseTool``.
144
127
 
145
128
  Subclasses ``BaseTool`` so framework-side ``isinstance(thing,
146
- Runnable)`` checks succeed natively (closes the v0.5.0 langgraph
147
- 1.0 / langchain_core 1.x regression). Every entry point the
148
- framework calls is explicitly overridden to gate via Guard.check
149
- before forwarding.
129
+ Runnable)`` checks succeed natively (langgraph 1.0 /
130
+ langchain_core 1.x reject composition wrappers). Every entry
131
+ point the framework calls is explicitly overridden to gate via
132
+ Guard.check before forwarding.
150
133
  """
151
134
 
152
135
  # Pydantic private attrs — held on the instance but not part of
@@ -504,7 +487,8 @@ def _build_guarded_tool_class() -> type:
504
487
  return await self._tool.abatch(inputs, config=config, **kwargs)
505
488
 
506
489
  # --------------------------------------------------------------
507
- # Legacy API (run / arun) — string-on-deny preserved for v0.4.x
490
+ # Legacy API (run / arun) — string-on-deny shape preserved for
491
+ # callers that depend on it; new code should prefer invoke().
508
492
  # --------------------------------------------------------------
509
493
 
510
494
  def run( # type: ignore[override]
@@ -515,9 +499,9 @@ def _build_guarded_tool_class() -> type:
515
499
  ) -> Any:
516
500
  """Run the tool (legacy entry).
517
501
 
518
- Returns a string message on DENY / REQUIRE_APPROVAL to preserve
519
- v0.4.x behavior; new code should prefer ``invoke`` so failures
520
- surface as exceptions.
502
+ Returns a string message on DENY / REQUIRE_APPROVAL (legacy
503
+ shape). New code should prefer ``invoke`` so failures surface
504
+ as exceptions.
521
505
  """
522
506
  result = self._gate(tool_input)
523
507
  inner = self._tool
@@ -76,7 +76,7 @@ def _infer_check_params_for(tool: "ToolDefinition", arguments: dict) -> dict:
76
76
  Both the in-process server and the gateway need identical inference +
77
77
  redaction. Defining the logic once at module scope keeps the gateway
78
78
  from awkwardly invoking the unbound server method on a non-server
79
- ``self`` (the v0.5 R7 E6 gateway preview is its own class).
79
+ ``self`` (the gateway is its own class).
80
80
  """
81
81
  from urllib.parse import urlparse # local import — only used here
82
82
 
@@ -181,8 +181,8 @@ class GuardedMCPServer:
181
181
  """Delegate to :func:`_infer_check_params_for`.
182
182
 
183
183
  Kept as a method for back-compat with any subclass / test that
184
- already binds it; the real logic (and v0.5 R7 T7 redaction) lives
185
- in the module-level helper so :class:`GuardedMCPGateway` can
184
+ binds it; the real logic (including secret redaction) lives in
185
+ the module-level helper so :class:`GuardedMCPGateway` can
186
186
  reuse it without instantiating a server.
187
187
  """
188
188
  return _infer_check_params_for(tool, arguments)
@@ -194,19 +194,17 @@ class GuardedMCPServer:
194
194
  params = request.get("params", {})
195
195
 
196
196
  if method == "initialize":
197
- # MCP clients advertise their protocol version in params. We pin to
198
- # MCP_PROTOCOL_VERSION and do not yet negotiate real negotiation
199
- # is a v0.5.0 design item. If the client wants a different version
200
- # (usually newer), log a single WARN to stderr so operators can
201
- # see version drift, then respond with our pinned version. stdout
202
- # is reserved for JSON-RPC on the stdio transport, so the warning
203
- # MUST go to stderr.
197
+ # MCP clients advertise their protocol version in params. We pin
198
+ # to MCP_PROTOCOL_VERSION and do not yet negotiate. If the client
199
+ # wants a different version (usually newer), log a single WARN to
200
+ # stderr so operators can see version drift, then respond with
201
+ # our pinned version. stdout is reserved for JSON-RPC on the
202
+ # stdio transport, so the warning MUST go to stderr.
204
203
  client_version = params.get("protocolVersion") if isinstance(params, dict) else None
205
204
  if client_version and client_version != MCP_PROTOCOL_VERSION:
206
205
  sys.stderr.write(
207
206
  f"WARN agentguard.mcp: client requested protocolVersion "
208
- f"{client_version!r}, pinning to {MCP_PROTOCOL_VERSION!r} "
209
- f"(negotiation is a v0.5.0 design item)\n"
207
+ f"{client_version!r}, pinning to {MCP_PROTOCOL_VERSION!r}\n"
210
208
  )
211
209
  sys.stderr.flush()
212
210
  return {
@@ -349,15 +347,15 @@ class GuardedMCPServer:
349
347
  def run(self):
350
348
  """Run the MCP server on stdio (blocking).
351
349
 
352
- v0.5 (R5 E6, S9): each frame is processed inside a guard so that
353
- a single malformed JSON line, a handler exception, or a
354
- downstream-side error does not crash the adapter. Claude Desktop
355
- (and similar long-lived MCP clients) keep one stdio session open
356
- for the whole session — surviving a bad frame means the user
357
- does not have to restart their editor every time a tool throws.
350
+ Each frame is processed inside a guard so a single malformed
351
+ JSON line, a handler exception, or a downstream-side error does
352
+ not crash the adapter. Claude Desktop (and similar long-lived
353
+ MCP clients) keep one stdio session open for the whole session
354
+ — surviving a bad frame means the user does not have to restart
355
+ their editor every time a tool throws.
358
356
 
359
357
  Behavior:
360
- - blank / whitespace-only line: dropped silently (back-compat).
358
+ - blank / whitespace-only line: dropped silently.
361
359
  - JSON parse error: log to stderr, drop the frame. Best-effort
362
360
  JSON-RPC parse-error response is not emitted because the bad
363
361
  frame had no recoverable id.
@@ -431,8 +429,7 @@ class _UpstreamProcess:
431
429
  matching the stdio JSON-RPC pattern Claude Desktop and Cursor use.
432
430
  Server-initiated notifications (``listChanged`` etc.) are not
433
431
  forwarded; capability merging and namespaced tool prefixes are
434
- deferred to v0.6's full Gateway implementation. v0.5 is a single
435
- upstream, request/response only.
432
+ deferred. Single upstream, request/response only.
436
433
  """
437
434
 
438
435
  def __init__(self, command: list, env: Optional[dict] = None):
@@ -503,20 +500,21 @@ class _UpstreamProcess:
503
500
  class GuardedMCPGateway:
504
501
  """Single-upstream MCP gateway with AgentGuard in the middle.
505
502
 
506
- v0.5 deliverable previewing the full Phase 4B Gateway. Spawns one
507
- downstream MCP server (e.g. ``npx -y @modelcontextprotocol/server-filesystem``),
508
- answers ``tools/list`` by forwarding to the downstream, and gates
509
- every ``tools/call`` through ``Guard.check`` before forwarding.
503
+ Spawns one downstream MCP server (e.g.
504
+ ``npx -y @modelcontextprotocol/server-filesystem``), answers
505
+ ``tools/list`` by forwarding to the downstream, and gates every
506
+ ``tools/call`` through ``Guard.check`` before forwarding.
510
507
 
511
- Limitations (closed in v0.6):
508
+ Limitations:
512
509
  - Single upstream — no capability merging.
513
510
  - No tool-name prefix / namespace separation.
514
511
  - Server-initiated notifications (``notifications/tools/list_changed``)
515
512
  are not forwarded.
516
513
  - No prompts/resources support — only ``tools/*`` is gated.
517
514
 
518
- Use :class:`GuardedMCPServer` directly when you want to register
519
- tools in code.
515
+ The full multi-upstream gateway lives in cmd/agentguard-mcp-gateway/
516
+ + pkg/mcpgw/. Use :class:`GuardedMCPServer` directly when you want
517
+ to register tools in code.
520
518
  """
521
519
 
522
520
  # Methods we always forward verbatim to the upstream.
@@ -549,7 +547,7 @@ class GuardedMCPGateway:
549
547
  Scope defaults to ``"shell"`` for unknown tools; ``_infer_check_params``
550
548
  upgrades to ``network`` / ``filesystem`` based on argument keys, so the
551
549
  default is mostly cosmetic. We do NOT try to read the description or
552
- schema to guess a smarter scope — keep the gateway simple in v0.5.
550
+ schema to guess a smarter scope — the simple inference is the contract.
553
551
  """
554
552
  try:
555
553
  resp = self._upstream.request({
@@ -699,16 +697,16 @@ def main():
699
697
  """Entry point for running as ``python -m agentguard.adapters.mcp``.
700
698
 
701
699
  Modes:
702
- - ``--upstream "<command...>"`` — gateway mode (R7 E6). Spawns the
700
+ - ``--upstream "<command...>"`` — gateway mode. Spawns the
703
701
  downstream MCP server given by the command, bridges JSON-RPC,
704
702
  and gates ``tools/call`` through AgentGuard. Example:
705
703
  ``python -m agentguard.adapters.mcp --guard-url http://localhost:8080
706
704
  --upstream "npx -y @modelcontextprotocol/server-filesystem /tmp"``.
707
- - no ``--upstream`` — empty-server mode. The original v0.4.x
708
- behavior, retained for back-compat with subclasses that
709
- ``register tools in code``. Emits a stderr WARN so an operator
710
- following the docs literally sees that no tools are registered
711
- (instead of silently exposing a tool-less server to Claude
705
+ - no ``--upstream`` — empty-server mode. Retained for callers that
706
+ ``register tools in code`` via subclasses. Emits a stderr WARN
707
+ so an operator following the docs literally sees that no tools
708
+ are registered (instead of silently exposing a tool-less server
709
+ to Claude
712
710
  Desktop).
713
711
  """
714
712
  import argparse
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentguardproxy
3
- Version: 0.5.1
3
+ Version: 0.5.2
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
@@ -12,14 +12,13 @@ Keywords: ai,agents,firewall,policy,guardrails,safety
12
12
  Classifier: Development Status :: 3 - Alpha
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: Programming Language :: Python :: 3
15
- Classifier: Programming Language :: Python :: 3.9
16
15
  Classifier: Programming Language :: Python :: 3.10
17
16
  Classifier: Programming Language :: Python :: 3.11
18
17
  Classifier: Programming Language :: Python :: 3.12
19
18
  Classifier: Programming Language :: Python :: 3.13
20
19
  Classifier: Topic :: Security
21
20
  Classifier: Topic :: Software Development :: Libraries
22
- Requires-Python: >=3.9
21
+ Requires-Python: >=3.10
23
22
  Description-Content-Type: text/markdown
24
23
  Provides-Extra: langchain
25
24
  Requires-Dist: langchain-core<2.0,>=0.3; extra == "langchain"
@@ -4,18 +4,18 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentguardproxy"
7
- version = "0.5.1"
7
+ version = "0.5.2"
8
8
  description = "Python SDK for AgentGuard — the firewall for AI agents"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
11
- # requires-python: 3.9 floor.
11
+ # requires-python: 3.10 floor.
12
12
  #
13
- # v0.5 drops 3.8 (R1 E1). 3.8 reached upstream end-of-life in October 2024 and
14
- # the pinned framework extras (langchain >=0.3, crewai >=0.80, mcp >=0.9) all
15
- # require 3.9+ themselves; keeping 3.8 in the trove only invited a broken
16
- # `pip install agentguardproxy[langchain]` resolution. The CI matrix exercises
17
- # 3.9, 3.10, 3.11, 3.12 on every push.
18
- requires-python = ">=3.9"
13
+ # 3.8 was dropped in v0.5.0 (upstream EOL October 2024). 3.9 was dropped
14
+ # after upstream EOL (October 2025) the `mcp` PyPI extra requires >=3.10
15
+ # anyway, so keeping 3.9 in the trove invited a broken
16
+ # `pip install agentguardproxy[mcp]` resolution. The CI matrix exercises
17
+ # 3.10, 3.11, 3.12 on every push.
18
+ requires-python = ">=3.10"
19
19
  authors = [
20
20
  { name = "Cauã Ferraz", email = "cauaferrazp@gmail.com" },
21
21
  ]
@@ -23,7 +23,6 @@ classifiers = [
23
23
  "Development Status :: 3 - Alpha",
24
24
  "Intended Audience :: Developers",
25
25
  "Programming Language :: Python :: 3",
26
- "Programming Language :: Python :: 3.9",
27
26
  "Programming Language :: Python :: 3.10",
28
27
  "Programming Language :: Python :: 3.11",
29
28
  "Programming Language :: Python :: 3.12",
@@ -111,11 +111,11 @@ class TestLangChainExtended:
111
111
  assert body["action"] == "write"
112
112
 
113
113
  def test_attribute_proxy_blocked(self, mock_server):
114
- """v0.5.1: arbitrary attribute access is blocked.
114
+ """v0.5.0: arbitrary attribute access is blocked.
115
115
 
116
116
  v0.5.0 used a composition wrapper with a custom ``__getattr__``
117
117
  allowlist that raised AttributeError with a "bypass" message.
118
- v0.5.1 switches to subclassing ``langchain_core.tools.BaseTool``,
118
+ v0.5.0 switches to subclassing ``langchain_core.tools.BaseTool``,
119
119
  so unknown attributes raise pydantic's standard "no such field"
120
120
  AttributeError instead. The security property is unchanged: the
121
121
  wrapper does not proxy arbitrary attributes to the wrapped tool.
@@ -262,7 +262,7 @@ class TestApprovalPath:
262
262
 
263
263
 
264
264
  # ---------------------------------------------------------------------------
265
- # Defense contract under the v0.5.1 hybrid pattern.
265
+ # Defense contract under the v0.5.0 hybrid pattern.
266
266
  #
267
267
  # v0.5.0 used a composition wrapper with a strict ``__getattr__`` allowlist
268
268
  # — every parent / unknown attribute access raised AttributeError. That
@@ -270,7 +270,7 @@ class TestApprovalPath:
270
270
  # BaseTool)`` failed (virtual-subclass registrations are no longer honored
271
271
  # by pydantic).
272
272
  #
273
- # v0.5.1 switches to a hybrid pattern: GuardedCrewTool **subclasses**
273
+ # v0.5.0 switches to a hybrid pattern: GuardedCrewTool **subclasses**
274
274
  # CrewAI's ``BaseTool`` so the framework's isinstance check passes, and
275
275
  # every dispatch entry point (``run``, ``_run``, ``invoke``, ``ainvoke``,
276
276
  # ``__call__``, ``_arun``, ``arun``, ``to_structured_tool``) is explicitly
@@ -134,7 +134,7 @@ class TestGuardCheck:
134
134
  def test_check_unreachable_fail_open(self):
135
135
  """fail_mode='allow' turns unreachable into ALLOW.
136
136
 
137
- Locks the Phase-5.1 parity-prep contract: the default is still
137
+ Locks the Phase-5.0 parity-prep contract: the default is still
138
138
  'deny' (see test_check_unreachable_fails_closed), and an explicit
139
139
  'allow' is required to opt into fail-open — mirroring the TS SDK
140
140
  where `failMode: 'allow'` has always been an explicit opt-in.
@@ -275,8 +275,15 @@ class TestBatch:
275
275
 
276
276
  out = gt.batch(["a", "b", "c"])
277
277
 
278
+ # `out` order matches input order (LangChain's batch preserves
279
+ # input/output alignment) but `calls` records execution order,
280
+ # which is non-deterministic when batch runs concurrently. The
281
+ # original assertion `calls == ["a","b","c"]` passed by luck on
282
+ # Python 3.10-3.12 schedulers but flaked on 3.13. Compare sorted
283
+ # so the assertion captures "all three inputs ran exactly once"
284
+ # without depending on the scheduler.
278
285
  assert out == ["echo:a", "echo:b", "echo:c"]
279
- assert calls == ["a", "b", "c"]
286
+ assert sorted(calls) == ["a", "b", "c"]
280
287
 
281
288
  def test_guardedtool_batch_denies_first(self, mock_server_indexed):
282
289
  """First entry DENY → batch raises with index 0 in the message,
@@ -331,9 +338,9 @@ class TestBatch:
331
338
 
332
339
 
333
340
  # ---------------------------------------------------------------------------
334
- # Defense contract under the v0.5.1 hybrid pattern.
341
+ # Defense contract under the v0.5.0 hybrid pattern.
335
342
  #
336
- # v0.5.0 used composition + a strict ``__getattr__`` allowlist; v0.5.1
343
+ # v0.5.0 used composition + a strict ``__getattr__`` allowlist; v0.5.0
337
344
  # subclasses langchain_core.tools.BaseTool so isinstance(thing, Runnable)
338
345
  # succeeds at framework boundaries (langgraph 1.0 / langchain_core 1.x)
339
346
  # and overrides every dispatch entry to keep gating tight.