agentguardproxy 0.5.2__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/PKG-INFO +1 -1
  2. agentguardproxy-0.9.0/agentguard/__init__.py +89 -0
  3. agentguardproxy-0.9.0/agentguard/adapters/_common.py +73 -0
  4. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/agentguard/adapters/crewai.py +2 -26
  5. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/agentguard/adapters/langchain.py +45 -44
  6. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/agentguard/adapters/mcp.py +12 -33
  7. agentguardproxy-0.5.2/agentguard/__init__.py → agentguardproxy-0.9.0/agentguard/core.py +7 -142
  8. agentguardproxy-0.9.0/agentguard/decorators.py +138 -0
  9. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/agentguardproxy.egg-info/PKG-INFO +1 -1
  10. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/agentguardproxy.egg-info/SOURCES.txt +3 -0
  11. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/pyproject.toml +1 -1
  12. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_langchain.py +91 -0
  13. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/README.md +0 -0
  14. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/agentguard/adapters/__init__.py +0 -0
  15. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/agentguard/adapters/browseruse.py +0 -0
  16. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/agentguardproxy.egg-info/dependency_links.txt +0 -0
  17. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/agentguardproxy.egg-info/requires.txt +0 -0
  18. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/agentguardproxy.egg-info/top_level.txt +0 -0
  19. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/setup.cfg +0 -0
  20. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_adapters.py +0 -0
  21. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_adapters_extended.py +0 -0
  22. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_browseruse.py +0 -0
  23. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_crewai.py +0 -0
  24. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_decorator.py +0 -0
  25. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_end_to_end_real_server.py +0 -0
  26. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_guard.py +0 -0
  27. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_integration.py +0 -0
  28. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_mcp.py +0 -0
  29. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_mcp_fuzz.py +0 -0
  30. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_mcp_gateway.py +0 -0
  31. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_polling_jitter.py +0 -0
  32. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_redactor_property.py +0 -0
  33. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_sdk_integration.py +0 -0
  34. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_sdk_polish.py +0 -0
  35. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_tenant_routing.py +0 -0
  36. {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/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.2
3
+ Version: 0.9.0
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,89 @@
1
+ """
2
+ AgentGuard Python SDK
3
+
4
+ Lightweight client for checking actions against AgentGuard policies.
5
+
6
+ Usage:
7
+ from agentguard import Guard
8
+
9
+ guard = Guard("http://localhost:8080")
10
+ result = guard.check("shell", command="rm -rf ./data")
11
+
12
+ if result.allowed:
13
+ execute(command)
14
+ elif result.needs_approval:
15
+ print(f"Approve at: {result.approval_url}")
16
+ else:
17
+ print(f"Blocked: {result.reason}")
18
+
19
+ Layout: the :class:`Guard` client, :class:`CheckResult`, exceptions and
20
+ constants live in :mod:`agentguard.core`; the ``@guarded`` decorator in
21
+ :mod:`agentguard.decorators`; framework integrations in
22
+ :mod:`agentguard.adapters`. Everything public is re-exported here —
23
+ import from the package root.
24
+ """
25
+
26
+ # Back-compat module aliases. The pre-split __init__ imported these
27
+ # stdlib modules directly, which made e.g. ``agentguard.request`` and
28
+ # ``agentguard.time`` valid mock.patch targets ("agentguard.request.
29
+ # urlopen", "agentguard.time.sleep", ...). Patching through these names
30
+ # mutates the shared stdlib module, so it intercepts agentguard.core's
31
+ # calls exactly as it did before the split. Do not remove without a
32
+ # deprecation cycle.
33
+ import functools # noqa: F401
34
+ import json # noqa: F401
35
+ import logging # noqa: F401
36
+ import os # noqa: F401
37
+ import random # noqa: F401
38
+ import time # noqa: F401
39
+ from urllib import request, error # noqa: F401
40
+ from urllib.parse import quote as urlquote # noqa: F401
41
+
42
+ from agentguard.core import (
43
+ DEFAULT_BASE_URL,
44
+ DEFAULT_TIMEOUT,
45
+ DEFAULT_APPROVAL_TIMEOUT,
46
+ DEFAULT_POLL_INTERVAL,
47
+ DECISION_ALLOW,
48
+ DECISION_DENY,
49
+ DECISION_REQUIRE_APPROVAL,
50
+ ENDPOINT_CHECK,
51
+ ENDPOINT_APPROVE,
52
+ ENDPOINT_DENY,
53
+ ENDPOINT_STATUS,
54
+ LOCAL_TENANT_ID,
55
+ FAIL_MODE_DENY,
56
+ FAIL_MODE_ALLOW,
57
+ AgentGuardError,
58
+ AgentGuardDenied,
59
+ AgentGuardApprovalRequired,
60
+ AgentGuardApprovalTimeout,
61
+ AgentGuardAuthError,
62
+ AgentGuardTimeoutError,
63
+ CheckResult,
64
+ Guard,
65
+ log,
66
+ )
67
+ from agentguard.decorators import guarded
68
+
69
+ # Public API surface. Everything else is internal.
70
+ __all__ = [
71
+ "Guard",
72
+ "CheckResult",
73
+ "guarded",
74
+ "AgentGuardError",
75
+ "AgentGuardDenied",
76
+ "AgentGuardApprovalRequired",
77
+ "AgentGuardApprovalTimeout",
78
+ "AgentGuardAuthError",
79
+ "DEFAULT_BASE_URL",
80
+ "DEFAULT_TIMEOUT",
81
+ "DEFAULT_APPROVAL_TIMEOUT",
82
+ "DEFAULT_POLL_INTERVAL",
83
+ "DECISION_ALLOW",
84
+ "DECISION_DENY",
85
+ "DECISION_REQUIRE_APPROVAL",
86
+ "LOCAL_TENANT_ID",
87
+ "FAIL_MODE_DENY",
88
+ "FAIL_MODE_ALLOW",
89
+ ]
@@ -0,0 +1,73 @@
1
+ """Shared check-parameter extraction for the framework adapters.
2
+
3
+ LangChain, CrewAI and MCP all reduce a tool invocation to the same
4
+ ``Guard.check`` keyword arguments (command, url, domain, path, action,
5
+ session_id, est_cost). The extraction lives here once so the adapters
6
+ cannot drift apart; each adapter keeps only its framework-specific
7
+ glue (input normalisation, error surfacing, redaction).
8
+ """
9
+
10
+ from typing import Any
11
+ from urllib.parse import urlparse
12
+
13
+
14
+ def domain_from_url(url: Any) -> str:
15
+ """Best-effort hostname extraction; "" when absent or malformed."""
16
+ if not isinstance(url, str):
17
+ return ""
18
+ try:
19
+ return urlparse(url).hostname or ""
20
+ except Exception:
21
+ return ""
22
+
23
+
24
+ def infer_path_action(tool_name: Any) -> str:
25
+ """Map a tool-name verb to the canonical filesystem action
26
+ ("read"/"write"/"delete"), or "" when no verb matches. Mirrors the
27
+ Go side's gateclient.InferFilesystemAction verb groups.
28
+ """
29
+ if not isinstance(tool_name, str):
30
+ return ""
31
+ name_lower = tool_name.lower()
32
+ if "read" in name_lower or "get" in name_lower:
33
+ return "read"
34
+ if "write" in name_lower or "save" in name_lower or "create" in name_lower:
35
+ return "write"
36
+ if "delete" in name_lower or "remove" in name_lower:
37
+ return "delete"
38
+ return ""
39
+
40
+
41
+ def extract_check_params(tool_input: Any, tool_name: Any = None) -> dict:
42
+ """Reduce a str/dict tool input to ``Guard.check`` keyword params.
43
+
44
+ A bare string is treated as a shell command. For dicts, recognised
45
+ keys are projected (command/cmd, url + derived domain,
46
+ path/file_path + action inferred from the tool-name verb,
47
+ session_id, est_cost); everything else is ignored.
48
+ """
49
+ params: dict = {}
50
+ if isinstance(tool_input, str):
51
+ params["command"] = tool_input
52
+ elif isinstance(tool_input, dict):
53
+ if "command" in tool_input or "cmd" in tool_input:
54
+ params["command"] = tool_input.get(
55
+ "command", tool_input.get("cmd", "")
56
+ )
57
+ if "url" in tool_input:
58
+ params["url"] = tool_input["url"]
59
+ domain = domain_from_url(tool_input["url"])
60
+ if domain:
61
+ params["domain"] = domain
62
+ if "path" in tool_input or "file_path" in tool_input:
63
+ params["path"] = tool_input.get(
64
+ "path", tool_input.get("file_path", "")
65
+ )
66
+ action = infer_path_action(tool_name)
67
+ if action:
68
+ params["action"] = action
69
+ if "session_id" in tool_input:
70
+ params["session_id"] = tool_input["session_id"]
71
+ if "est_cost" in tool_input:
72
+ params["est_cost"] = tool_input["est_cost"]
73
+ return params
@@ -68,6 +68,7 @@ from agentguard import (
68
68
  AgentGuardDenied,
69
69
  Guard,
70
70
  )
71
+ from agentguard.adapters._common import extract_check_params
71
72
 
72
73
 
73
74
  # ---------------------------------------------------------------------------
@@ -226,32 +227,7 @@ def _build_guarded_class() -> type:
226
227
 
227
228
  def _extract_check_params(self, tool_input: Any) -> dict:
228
229
  """Extract check params from str/dict tool input."""
229
- params: dict = {}
230
- if isinstance(tool_input, str):
231
- params["command"] = tool_input
232
- elif isinstance(tool_input, dict):
233
- if "command" in tool_input or "cmd" in tool_input:
234
- params["command"] = tool_input.get(
235
- "command", tool_input.get("cmd", "")
236
- )
237
- if "url" in tool_input:
238
- params["url"] = tool_input["url"]
239
- try:
240
- from urllib.parse import urlparse
241
- parsed = urlparse(tool_input["url"])
242
- if parsed.hostname:
243
- params["domain"] = parsed.hostname
244
- except Exception:
245
- pass
246
- if "path" in tool_input or "file_path" in tool_input:
247
- params["path"] = tool_input.get(
248
- "path", tool_input.get("file_path", "")
249
- )
250
- if "session_id" in tool_input:
251
- params["session_id"] = tool_input["session_id"]
252
- if "est_cost" in tool_input:
253
- params["est_cost"] = tool_input["est_cost"]
254
- return params
230
+ return extract_check_params(tool_input, self.name)
255
231
 
256
232
  # ----------------------------------------------------------
257
233
  # The single shared gate.
@@ -24,8 +24,8 @@ entry point the framework calls is explicitly overridden:
24
24
  - ``invoke`` / ``ainvoke`` — overridden anyway so the gate fires
25
25
  even if a future framework version short-circuits the parent's
26
26
  invoke -> run -> _run chain.
27
- - ``stream`` / ``astream`` — gated once at stream open; per-chunk
28
- gating is a v0.6 issue.
27
+ - ``stream`` / ``astream`` — gated at stream open + periodic
28
+ re-validation (see "Stream gating" below).
29
29
  - ``batch`` / ``abatch`` — gate each input independently; first DENY
30
30
  raises for the whole batch (whole-batch-fails-on-first-deny).
31
31
  - ``run`` / ``arun`` — legacy entries; preserve string-on-deny
@@ -46,19 +46,30 @@ extra. The class definition is wrapped in a builder that resolves
46
46
  the ``GuardedTool`` symbol is a callable factory that raises a clear
47
47
  ``ImportError`` when constructed.
48
48
 
49
- Stream gating limitation
50
- ========================
49
+ Stream gating
50
+ =============
51
51
 
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()``.
52
+ ``stream`` / ``astream`` gate at stream open and re-validate the
53
+ decision every ``STREAM_REGATE_SECONDS`` (default 10s) while chunks
54
+ flow, so a mid-stream policy revocation cuts the stream off instead of
55
+ riding to completion.
55
56
  """
56
57
 
57
58
  from __future__ import annotations
58
59
 
60
+ import time
59
61
  from typing import Any, AsyncIterator, Iterator, List, Optional
60
62
 
61
63
  from agentguard import Guard, CheckResult, DEFAULT_BASE_URL
64
+ from agentguard.adapters._common import extract_check_params
65
+
66
+ # How often a live stream re-validates its policy decision. Streams gate
67
+ # at open; without re-validation a long-lived stream would ride to
68
+ # completion even after a policy hot-reload revoked the permission.
69
+ # Re-checking costs one local /v1/check HTTP round-trip, so it runs on a
70
+ # wall-clock interval rather than per chunk. Tests shrink this to force
71
+ # a re-check on every chunk.
72
+ STREAM_REGATE_SECONDS = 10.0
62
73
 
63
74
 
64
75
  # ---------------------------------------------------------------------------
@@ -177,39 +188,7 @@ def _build_guarded_tool_class() -> type:
177
188
 
178
189
  def _infer_check_params(self, tool_input: Any) -> dict:
179
190
  """Extract meaningful parameters from tool input."""
180
- params: dict = {}
181
- if isinstance(tool_input, str):
182
- params["command"] = tool_input
183
- elif isinstance(tool_input, dict):
184
- if "command" in tool_input or "cmd" in tool_input:
185
- params["command"] = tool_input.get(
186
- "command", tool_input.get("cmd", "")
187
- )
188
- if "url" in tool_input:
189
- params["url"] = tool_input["url"]
190
- try:
191
- from urllib.parse import urlparse
192
- parsed = urlparse(tool_input["url"])
193
- if parsed.hostname:
194
- params["domain"] = parsed.hostname
195
- except Exception:
196
- pass
197
- if "path" in tool_input or "file_path" in tool_input:
198
- params["path"] = tool_input.get(
199
- "path", tool_input.get("file_path", "")
200
- )
201
- name_lower = self.name.lower() if isinstance(self.name, str) else ""
202
- if "read" in name_lower or "get" in name_lower:
203
- params["action"] = "read"
204
- elif "write" in name_lower or "save" in name_lower or "create" in name_lower:
205
- params["action"] = "write"
206
- elif "delete" in name_lower or "remove" in name_lower:
207
- params["action"] = "delete"
208
- if "session_id" in tool_input:
209
- params["session_id"] = tool_input["session_id"]
210
- if "est_cost" in tool_input:
211
- params["est_cost"] = tool_input["est_cost"]
212
- return params
191
+ return extract_check_params(tool_input, self.name)
213
192
 
214
193
  def _infer_scope(self, params: dict) -> str:
215
194
  if params.get("domain") or params.get("url"):
@@ -421,12 +400,27 @@ def _build_guarded_tool_class() -> type:
421
400
  config: Any = None,
422
401
  **kwargs: Any,
423
402
  ) -> Iterator[Any]:
424
- """Synchronous streaming Runnable entry. Gate fires once at
425
- stream open. TODO(v0.6): gate per chunk."""
403
+ """Synchronous streaming Runnable entry. Gates at stream
404
+ open, then re-validates every ``STREAM_REGATE_SECONDS``
405
+ while chunks flow — a policy change mid-stream (permission
406
+ revoked via hot-reload) cuts the stream off with the same
407
+ typed ``PermissionError`` instead of riding to completion.
408
+ """
426
409
  result = self._gate(input)
427
410
  self._raise_for_modern_api(result, input)
428
411
  inner = self._tool
429
- return inner.stream(input, config=config, **kwargs)
412
+
413
+ def guarded_chunks() -> Iterator[Any]:
414
+ last_check = time.monotonic()
415
+ for chunk in inner.stream(input, config=config, **kwargs):
416
+ now = time.monotonic()
417
+ if now - last_check >= STREAM_REGATE_SECONDS:
418
+ recheck = self._gate(input)
419
+ self._raise_for_modern_api(recheck, input)
420
+ last_check = now
421
+ yield chunk
422
+
423
+ return guarded_chunks()
430
424
 
431
425
  async def astream( # type: ignore[override]
432
426
  self,
@@ -434,11 +428,18 @@ def _build_guarded_tool_class() -> type:
434
428
  config: Any = None,
435
429
  **kwargs: Any,
436
430
  ) -> AsyncIterator[Any]:
437
- """Async streaming Runnable entry. Same one-shot gate."""
431
+ """Async streaming Runnable entry. Same open-gate +
432
+ periodic re-validation contract as :meth:`stream`."""
438
433
  result = self._gate(input)
439
434
  self._raise_for_modern_api(result, input)
440
435
  inner = self._tool
436
+ last_check = time.monotonic()
441
437
  async for chunk in inner.astream(input, config=config, **kwargs):
438
+ now = time.monotonic()
439
+ if now - last_check >= STREAM_REGATE_SECONDS:
440
+ recheck = self._gate(input)
441
+ self._raise_for_modern_api(recheck, input)
442
+ last_check = now
442
443
  yield chunk
443
444
 
444
445
  def batch( # type: ignore[override]
@@ -37,10 +37,11 @@ import re
37
37
  import sys
38
38
  from typing import Any, Callable, Dict, List, Optional
39
39
  from agentguard import Guard, CheckResult, DEFAULT_BASE_URL
40
+ from agentguard.adapters._common import extract_check_params
40
41
 
41
42
  # MCP protocol constants
42
43
  MCP_PROTOCOL_VERSION = "2024-11-05"
43
- SDK_VERSION = "0.5.1"
44
+ SDK_VERSION = "0.9.0"
44
45
 
45
46
  # Secret patterns mirrored from pkg/notify/notify.go's DefaultRedactor. The
46
47
  # MCP adapter forwards handler exception text back to the client as a
@@ -77,46 +78,24 @@ def _infer_check_params_for(tool: "ToolDefinition", arguments: dict) -> dict:
77
78
  redaction. Defining the logic once at module scope keeps the gateway
78
79
  from awkwardly invoking the unbound server method on a non-server
79
80
  ``self`` (the gateway is its own class).
80
- """
81
- from urllib.parse import urlparse # local import — only used here
82
81
 
83
- params: Dict[str, Any] = {}
82
+ Builds on the shared :func:`extract_check_params` and adds the
83
+ MCP-specific parts: secret redaction on commands, a synthesised
84
+ command for shell-scope tools without an explicit one, and a bare
85
+ ``domain`` argument passthrough.
86
+ """
87
+ params: Dict[str, Any] = extract_check_params(arguments, tool.name)
84
88
 
85
- if "command" in arguments or "cmd" in arguments:
86
- raw_cmd = arguments.get("command", arguments.get("cmd", ""))
87
- params["command"] = _redact(raw_cmd) if isinstance(raw_cmd, str) else raw_cmd
89
+ if "command" in params:
90
+ cmd = params["command"]
91
+ if isinstance(cmd, str):
92
+ params["command"] = _redact(cmd)
88
93
  elif tool.scope == "shell":
89
94
  params["command"] = _redact(f"{tool.name} {json.dumps(arguments)}")
90
95
 
91
- if "url" in arguments:
92
- params["url"] = arguments["url"]
93
- try:
94
- parsed = urlparse(arguments["url"])
95
- if parsed.hostname:
96
- params["domain"] = parsed.hostname
97
- except Exception:
98
- # urlparse practically never raises but defensive — a malformed
99
- # URL must not crash the policy check.
100
- pass
101
-
102
- if "path" in arguments or "file_path" in arguments:
103
- params["path"] = arguments.get("path", arguments.get("file_path", ""))
104
- name_lower = tool.name.lower()
105
- if "read" in name_lower or "get" in name_lower:
106
- params["action"] = "read"
107
- elif "write" in name_lower or "save" in name_lower:
108
- params["action"] = "write"
109
- elif "delete" in name_lower or "remove" in name_lower:
110
- params["action"] = "delete"
111
-
112
96
  if "domain" in arguments:
113
97
  params["domain"] = arguments["domain"]
114
98
 
115
- if "session_id" in arguments:
116
- params["session_id"] = arguments["session_id"]
117
- if "est_cost" in arguments:
118
- params["est_cost"] = arguments["est_cost"]
119
-
120
99
  return params
121
100
 
122
101
 
@@ -1,33 +1,21 @@
1
+ """Core AgentGuard SDK: the :class:`Guard` HTTP client, the
2
+ :class:`CheckResult` decision shape, the typed exceptions, and the
3
+ wire-level constants. Import from the ``agentguard`` package root —
4
+ this module is an implementation detail; the package ``__init__``
5
+ re-exports the public surface.
1
6
  """
2
- AgentGuard Python SDK
3
7
 
4
- Lightweight client for checking actions against AgentGuard policies.
5
-
6
- Usage:
7
- from agentguard import Guard
8
-
9
- guard = Guard("http://localhost:8080")
10
- result = guard.check("shell", command="rm -rf ./data")
11
-
12
- if result.allowed:
13
- execute(command)
14
- elif result.needs_approval:
15
- print(f"Approve at: {result.approval_url}")
16
- else:
17
- print(f"Blocked: {result.reason}")
18
- """
19
-
20
- import functools
21
8
  import json
22
9
  import logging
23
10
  import os
24
11
  import random
25
12
  import time
26
- from dataclasses import dataclass, field
13
+ from dataclasses import dataclass
27
14
  from typing import Optional
28
15
  from urllib import request, error
29
16
  from urllib.parse import quote as urlquote
30
17
 
18
+
31
19
  # Module-level logger. Used for non-fatal warnings (HTTP shape mismatches,
32
20
  # unexpected content types) where the SDK still returns a CheckResult per
33
21
  # the configured fail-mode but operators want visibility into the underlying
@@ -510,126 +498,3 @@ class Guard:
510
498
  time.sleep(actual_sleep)
511
499
 
512
500
  return CheckResult(decision=DECISION_DENY, reason="Approval timed out")
513
-
514
-
515
- # Set of kwargs that ``guarded(**check_kwargs)`` is allowed to forward to
516
- # ``Guard.check``. Anything outside this set is almost always a typo
517
- # (e.g. ``agent="x"`` instead of ``meta={"agent":"x"}``) — and silently
518
- # forwarding it to ``Guard.check`` would either be dropped on the floor
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.
522
- _GUARDED_VALID_CHECK_KWARGS = frozenset({
523
- "action",
524
- "command",
525
- "path",
526
- "domain",
527
- "url",
528
- "session_id",
529
- "est_cost",
530
- "meta",
531
- })
532
-
533
-
534
- # Convenience decorator for guarding functions
535
- def guarded(
536
- scope: str,
537
- guard: Optional[Guard] = None,
538
- *,
539
- wait_for_approval: bool = False,
540
- approval_timeout: int = DEFAULT_APPROVAL_TIMEOUT,
541
- approval_poll_interval: int = DEFAULT_POLL_INTERVAL,
542
- **check_kwargs,
543
- ):
544
- """Decorator that checks policy before executing a function.
545
-
546
- Default behavior (v0.4.0/v0.4.1 compatible):
547
- @guarded("shell", guard=guard)
548
- def run_command(cmd: str):
549
- os.system(cmd)
550
-
551
- run_command("ls") # executes if ALLOW
552
- run_command("rm -rf /") # raises AgentGuardDenied (a PermissionError)
553
-
554
- Opt-in block-until-resolved:
555
- @guarded(
556
- "cost",
557
- guard=guard,
558
- wait_for_approval=True,
559
- approval_timeout=300,
560
- approval_poll_interval=2,
561
- )
562
- def expensive_call(prompt: str):
563
- ...
564
-
565
- On REQUIRE_APPROVAL, with ``wait_for_approval=True`` the wrapper calls
566
- :meth:`Guard.wait_for_approval` and then dispatches on the resolved
567
- decision: ALLOW runs the function, DENY raises :class:`AgentGuardDenied`,
568
- a synthetic "Approval timed out" raises :class:`AgentGuardApprovalTimeout`.
569
- With ``wait_for_approval=False`` (default), the wrapper raises
570
- :class:`AgentGuardApprovalRequired` immediately. That class extends
571
- ``PermissionError``, so existing ``except PermissionError:`` handlers
572
- continue to work unchanged.
573
-
574
- Unknown ``**check_kwargs`` raise ``TypeError`` at decoration time.
575
- Only the keyword arguments :meth:`Guard.check` understands
576
- (``action``, ``command``, ``path``, ``domain``, ``url``,
577
- ``session_id``, ``est_cost``, ``meta``) are accepted; a typo like
578
- ``agent="x"`` is rejected at definition time rather than silently
579
- dropped.
580
- """
581
- unknown = set(check_kwargs) - _GUARDED_VALID_CHECK_KWARGS
582
- if unknown:
583
- raise TypeError(
584
- f"@guarded got unexpected keyword arguments: "
585
- f"{sorted(unknown)!r}. Valid options: "
586
- f"{sorted(_GUARDED_VALID_CHECK_KWARGS)!r}"
587
- )
588
-
589
- def decorator(func):
590
- @functools.wraps(func)
591
- def wrapper(*args, **kwargs):
592
- g = guard or Guard()
593
- # Try to extract meaningful info from args
594
- cmd = args[0] if args else kwargs.get("command", kwargs.get("cmd", ""))
595
- result = g.check(scope, command=str(cmd), **check_kwargs)
596
-
597
- if result.allowed:
598
- return func(*args, **kwargs)
599
-
600
- if result.needs_approval:
601
- if wait_for_approval:
602
- # Block until a human resolves or the deadline elapses.
603
- resolved = g.wait_for_approval(
604
- result.approval_id,
605
- timeout=approval_timeout,
606
- poll_interval=approval_poll_interval,
607
- )
608
- if resolved.allowed:
609
- return func(*args, **kwargs)
610
- if resolved.denied and resolved.reason == "Approval timed out":
611
- raise AgentGuardApprovalTimeout(
612
- f"Approval for {result.approval_id} timed out after "
613
- f"{approval_timeout}s",
614
- result=resolved,
615
- approval_id=result.approval_id,
616
- )
617
- raise AgentGuardDenied(
618
- f"Action denied by AgentGuard: {resolved.reason}",
619
- result=resolved,
620
- )
621
- raise AgentGuardApprovalRequired(
622
- # Stable message text — text-matchers in caller code
623
- # depend on it.
624
- f"Action requires approval. Approve at: {result.approval_url}",
625
- result=result,
626
- approval_id=result.approval_id,
627
- approval_url=result.approval_url,
628
- )
629
-
630
- raise AgentGuardDenied(
631
- f"Action denied by AgentGuard: {result.reason}",
632
- result=result,
633
- )
634
- return wrapper
635
- return decorator
@@ -0,0 +1,138 @@
1
+ """The ``@guarded`` decorator: policy-check-then-call for plain
2
+ functions. Framework-specific integration lives in
3
+ ``agentguard.adapters``; this module only depends on the core client.
4
+ """
5
+
6
+ import functools
7
+ from typing import Optional
8
+
9
+ from agentguard.core import (
10
+ DEFAULT_APPROVAL_TIMEOUT,
11
+ DEFAULT_POLL_INTERVAL,
12
+ AgentGuardApprovalRequired,
13
+ AgentGuardApprovalTimeout,
14
+ AgentGuardDenied,
15
+ Guard,
16
+ )
17
+
18
+ # Set of kwargs that ``guarded(**check_kwargs)`` is allowed to forward to
19
+ # ``Guard.check``. Anything outside this set is almost always a typo
20
+ # (e.g. ``agent="x"`` instead of ``meta={"agent":"x"}``) — and silently
21
+ # forwarding it to ``Guard.check`` would either be dropped on the floor
22
+ # (``check`` only inspects keyword args it knows about) or raise a
23
+ # confusing TypeError much later in the call chain. Reject at the
24
+ # decorator boundary so the typo surfaces at definition time.
25
+ _GUARDED_VALID_CHECK_KWARGS = frozenset({
26
+ "action",
27
+ "command",
28
+ "path",
29
+ "domain",
30
+ "url",
31
+ "session_id",
32
+ "est_cost",
33
+ "meta",
34
+ })
35
+
36
+
37
+ # Convenience decorator for guarding functions
38
+ def guarded(
39
+ scope: str,
40
+ guard: Optional[Guard] = None,
41
+ *,
42
+ wait_for_approval: bool = False,
43
+ approval_timeout: int = DEFAULT_APPROVAL_TIMEOUT,
44
+ approval_poll_interval: int = DEFAULT_POLL_INTERVAL,
45
+ **check_kwargs,
46
+ ):
47
+ """Decorator that checks policy before executing a function.
48
+
49
+ Default behavior (v0.4.0/v0.4.1 compatible):
50
+ @guarded("shell", guard=guard)
51
+ def run_command(cmd: str):
52
+ os.system(cmd)
53
+
54
+ run_command("ls") # executes if ALLOW
55
+ run_command("rm -rf /") # raises AgentGuardDenied (a PermissionError)
56
+
57
+ Opt-in block-until-resolved:
58
+ @guarded(
59
+ "cost",
60
+ guard=guard,
61
+ wait_for_approval=True,
62
+ approval_timeout=300,
63
+ approval_poll_interval=2,
64
+ )
65
+ def expensive_call(prompt: str):
66
+ ...
67
+
68
+ On REQUIRE_APPROVAL, with ``wait_for_approval=True`` the wrapper calls
69
+ :meth:`Guard.wait_for_approval` and then dispatches on the resolved
70
+ decision: ALLOW runs the function, DENY raises :class:`AgentGuardDenied`,
71
+ a synthetic "Approval timed out" raises :class:`AgentGuardApprovalTimeout`.
72
+ With ``wait_for_approval=False`` (default), the wrapper raises
73
+ :class:`AgentGuardApprovalRequired` immediately. That class extends
74
+ ``PermissionError``, so existing ``except PermissionError:`` handlers
75
+ continue to work unchanged.
76
+
77
+ Unknown ``**check_kwargs`` raise ``TypeError`` at decoration time.
78
+ Only the keyword arguments :meth:`Guard.check` understands
79
+ (``action``, ``command``, ``path``, ``domain``, ``url``,
80
+ ``session_id``, ``est_cost``, ``meta``) are accepted; a typo like
81
+ ``agent="x"`` is rejected at definition time rather than silently
82
+ dropped.
83
+ """
84
+ unknown = set(check_kwargs) - _GUARDED_VALID_CHECK_KWARGS
85
+ if unknown:
86
+ raise TypeError(
87
+ f"@guarded got unexpected keyword arguments: "
88
+ f"{sorted(unknown)!r}. Valid options: "
89
+ f"{sorted(_GUARDED_VALID_CHECK_KWARGS)!r}"
90
+ )
91
+
92
+ def decorator(func):
93
+ @functools.wraps(func)
94
+ def wrapper(*args, **kwargs):
95
+ g = guard or Guard()
96
+ # Try to extract meaningful info from args
97
+ cmd = args[0] if args else kwargs.get("command", kwargs.get("cmd", ""))
98
+ result = g.check(scope, command=str(cmd), **check_kwargs)
99
+
100
+ if result.allowed:
101
+ return func(*args, **kwargs)
102
+
103
+ if result.needs_approval:
104
+ if wait_for_approval:
105
+ # Block until a human resolves or the deadline elapses.
106
+ resolved = g.wait_for_approval(
107
+ result.approval_id,
108
+ timeout=approval_timeout,
109
+ poll_interval=approval_poll_interval,
110
+ )
111
+ if resolved.allowed:
112
+ return func(*args, **kwargs)
113
+ if resolved.denied and resolved.reason == "Approval timed out":
114
+ raise AgentGuardApprovalTimeout(
115
+ f"Approval for {result.approval_id} timed out after "
116
+ f"{approval_timeout}s",
117
+ result=resolved,
118
+ approval_id=result.approval_id,
119
+ )
120
+ raise AgentGuardDenied(
121
+ f"Action denied by AgentGuard: {resolved.reason}",
122
+ result=resolved,
123
+ )
124
+ raise AgentGuardApprovalRequired(
125
+ # Stable message text — text-matchers in caller code
126
+ # depend on it.
127
+ f"Action requires approval. Approve at: {result.approval_url}",
128
+ result=result,
129
+ approval_id=result.approval_id,
130
+ approval_url=result.approval_url,
131
+ )
132
+
133
+ raise AgentGuardDenied(
134
+ f"Action denied by AgentGuard: {result.reason}",
135
+ result=result,
136
+ )
137
+ return wrapper
138
+ return decorator
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentguardproxy
3
- Version: 0.5.2
3
+ Version: 0.9.0
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
@@ -1,7 +1,10 @@
1
1
  README.md
2
2
  pyproject.toml
3
3
  agentguard/__init__.py
4
+ agentguard/core.py
5
+ agentguard/decorators.py
4
6
  agentguard/adapters/__init__.py
7
+ agentguard/adapters/_common.py
5
8
  agentguard/adapters/browseruse.py
6
9
  agentguard/adapters/crewai.py
7
10
  agentguard/adapters/langchain.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentguardproxy"
7
- version = "0.5.2"
7
+ version = "0.9.0"
8
8
  description = "Python SDK for AgentGuard — the firewall for AI agents"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -564,3 +564,94 @@ class TestLegacyRunArun:
564
564
  assert isinstance(out, str)
565
565
  assert "denied" in out.lower()
566
566
  assert calls == []
567
+
568
+
569
+ class TestStreamRegating:
570
+ """stream()/astream() gate at open and re-validate periodically so a
571
+ mid-stream policy revocation cuts the stream off."""
572
+
573
+ class _FlippingGuard:
574
+ """Allows the first N checks, denies afterwards."""
575
+
576
+ def __init__(self, allow_first: int):
577
+ self.allow_first = allow_first
578
+ self.calls = 0
579
+
580
+ def check(self, scope, **params):
581
+ from agentguard import CheckResult, DECISION_ALLOW, DECISION_DENY
582
+ self.calls += 1
583
+ if self.calls <= self.allow_first:
584
+ return CheckResult(decision=DECISION_ALLOW, reason="ok")
585
+ return CheckResult(decision=DECISION_DENY, reason="revoked mid-stream")
586
+
587
+ class _StreamingTool:
588
+ name = "streaming_tool"
589
+ description = "yields three chunks"
590
+
591
+ def stream(self, input, config=None, **kwargs): # noqa: A002
592
+ yield "chunk-1"
593
+ yield "chunk-2"
594
+ yield "chunk-3"
595
+
596
+ async def astream(self, input, config=None, **kwargs): # noqa: A002
597
+ for c in ("chunk-1", "chunk-2", "chunk-3"):
598
+ yield c
599
+
600
+ def _run(self, *a, **k):
601
+ return "done"
602
+
603
+ def test_stream_cut_off_on_mid_stream_revocation(self, monkeypatch):
604
+ from agentguard.adapters import langchain as lc_mod
605
+ from agentguard.adapters.langchain import GuardedTool
606
+
607
+ # Re-validate on every chunk so the revocation lands deterministically.
608
+ monkeypatch.setattr(lc_mod, "STREAM_REGATE_SECONDS", 0.0)
609
+
610
+ guard = self._FlippingGuard(allow_first=1) # open-gate passes, first re-check denies
611
+ gt = GuardedTool(self._StreamingTool(), guard, scope="shell")
612
+
613
+ it = gt.stream("run")
614
+ with pytest.raises(PermissionError, match="revoked mid-stream"):
615
+ list(it)
616
+ assert guard.calls >= 2 # open gate + at least one re-check
617
+
618
+ def test_stream_completes_when_policy_stays_allowed(self, monkeypatch):
619
+ from agentguard.adapters import langchain as lc_mod
620
+ from agentguard.adapters.langchain import GuardedTool
621
+
622
+ monkeypatch.setattr(lc_mod, "STREAM_REGATE_SECONDS", 0.0)
623
+ guard = self._FlippingGuard(allow_first=10**9)
624
+ gt = GuardedTool(self._StreamingTool(), guard, scope="shell")
625
+
626
+ assert list(gt.stream("run")) == ["chunk-1", "chunk-2", "chunk-3"]
627
+ assert guard.calls >= 4 # open gate + one re-check per chunk
628
+
629
+ def test_astream_cut_off_on_mid_stream_revocation(self, monkeypatch):
630
+ import asyncio
631
+
632
+ from agentguard.adapters import langchain as lc_mod
633
+ from agentguard.adapters.langchain import GuardedTool
634
+
635
+ monkeypatch.setattr(lc_mod, "STREAM_REGATE_SECONDS", 0.0)
636
+ guard = self._FlippingGuard(allow_first=1)
637
+ gt = GuardedTool(self._StreamingTool(), guard, scope="shell")
638
+
639
+ async def consume():
640
+ chunks = []
641
+ async for c in gt.astream("run"):
642
+ chunks.append(c)
643
+ return chunks
644
+
645
+ with pytest.raises(PermissionError, match="revoked mid-stream"):
646
+ asyncio.run(consume())
647
+
648
+ def test_default_interval_does_not_recheck_fast_streams(self):
649
+ from agentguard.adapters.langchain import GuardedTool
650
+
651
+ guard = self._FlippingGuard(allow_first=1) # any re-check would deny
652
+ gt = GuardedTool(self._StreamingTool(), guard, scope="shell")
653
+
654
+ # With the default 10s interval a sub-second stream never re-gates,
655
+ # so the hot path costs exactly one check.
656
+ assert list(gt.stream("run")) == ["chunk-1", "chunk-2", "chunk-3"]
657
+ assert guard.calls == 1