agentguardproxy 0.6.0__tar.gz → 1.0.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 (37) hide show
  1. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/PKG-INFO +1 -1
  2. agentguardproxy-1.0.0/agentguard/__init__.py +89 -0
  3. agentguardproxy-1.0.0/agentguard/adapters/_common.py +99 -0
  4. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/agentguard/adapters/crewai.py +2 -26
  5. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/agentguard/adapters/langchain.py +45 -44
  6. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/agentguard/adapters/mcp.py +12 -33
  7. agentguardproxy-0.6.0/agentguard/__init__.py → agentguardproxy-1.0.0/agentguard/core.py +7 -142
  8. agentguardproxy-1.0.0/agentguard/decorators.py +138 -0
  9. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/agentguardproxy.egg-info/PKG-INFO +1 -1
  10. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/agentguardproxy.egg-info/SOURCES.txt +4 -0
  11. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/pyproject.toml +1 -1
  12. agentguardproxy-1.0.0/tests/test_action_parity.py +113 -0
  13. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/tests/test_langchain.py +91 -0
  14. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/README.md +0 -0
  15. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/agentguard/adapters/__init__.py +0 -0
  16. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/agentguard/adapters/browseruse.py +0 -0
  17. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/agentguardproxy.egg-info/dependency_links.txt +0 -0
  18. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/agentguardproxy.egg-info/requires.txt +0 -0
  19. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/agentguardproxy.egg-info/top_level.txt +0 -0
  20. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/setup.cfg +0 -0
  21. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/tests/test_adapters.py +0 -0
  22. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/tests/test_adapters_extended.py +0 -0
  23. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/tests/test_browseruse.py +0 -0
  24. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/tests/test_crewai.py +0 -0
  25. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/tests/test_decorator.py +0 -0
  26. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/tests/test_end_to_end_real_server.py +0 -0
  27. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/tests/test_guard.py +0 -0
  28. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/tests/test_integration.py +0 -0
  29. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/tests/test_mcp.py +0 -0
  30. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/tests/test_mcp_fuzz.py +0 -0
  31. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/tests/test_mcp_gateway.py +0 -0
  32. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/tests/test_polling_jitter.py +0 -0
  33. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/tests/test_redactor_property.py +0 -0
  34. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/tests/test_sdk_integration.py +0 -0
  35. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/tests/test_sdk_polish.py +0 -0
  36. {agentguardproxy-0.6.0 → agentguardproxy-1.0.0}/tests/test_tenant_routing.py +0 -0
  37. {agentguardproxy-0.6.0 → agentguardproxy-1.0.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.6.0
3
+ Version: 1.0.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,99 @@
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
+ # Filesystem-action verb groups. These MUST stay byte-identical to the Go
25
+ # transports' gateclient.InferFilesystemAction (pkg/internal/gateclient/
26
+ # gateclient.go): same verbs, same groups, same precedence (read, then
27
+ # write, then delete). Parity is pinned by two mirrored test tables —
28
+ # plugins/python/tests/test_action_parity.py and
29
+ # pkg/internal/gateclient/gateclient_test.go. Edit both sides together.
30
+ _READ_PREFIXES = ("read", "list", "get", "stat", "cat", "find", "glob")
31
+ _WRITE_PREFIXES = ("write", "edit", "create", "append", "save", "copy", "move")
32
+ _DELETE_PREFIXES = ("delete", "remove", "unlink", "rm")
33
+
34
+
35
+ def infer_path_action(tool_name: Any) -> str:
36
+ """Map a tool-name verb to the canonical filesystem action
37
+ ("read"/"write"/"delete"), or "" when no verb matches.
38
+
39
+ Mirrors the Go transports' gateclient.InferFilesystemAction exactly:
40
+ a case-insensitive ``strings.HasPrefix`` (Python ``str.startswith``)
41
+ against these verb groups, checked in this order —
42
+
43
+ read = read, list, get, stat, cat, find, glob
44
+ write = write, edit, create, append, save, copy, move
45
+ delete = delete, remove, unlink, rm
46
+
47
+ Prefix (not substring) matching is deliberate: it avoids the false
48
+ positives substring matching produces that Go never makes — e.g.
49
+ "set_target" contains "get" but does not START with it, and
50
+ "undelete_x" contains "delete" but does not start with it. Both
51
+ correctly yield "" here, matching Go. Parity is pinned by
52
+ plugins/python/tests/test_action_parity.py, whose table mirrors
53
+ pkg/internal/gateclient/gateclient_test.go.
54
+ """
55
+ if not isinstance(tool_name, str):
56
+ return ""
57
+ name_lower = tool_name.lower()
58
+ if name_lower.startswith(_READ_PREFIXES):
59
+ return "read"
60
+ if name_lower.startswith(_WRITE_PREFIXES):
61
+ return "write"
62
+ if name_lower.startswith(_DELETE_PREFIXES):
63
+ return "delete"
64
+ return ""
65
+
66
+
67
+ def extract_check_params(tool_input: Any, tool_name: Any = None) -> dict:
68
+ """Reduce a str/dict tool input to ``Guard.check`` keyword params.
69
+
70
+ A bare string is treated as a shell command. For dicts, recognised
71
+ keys are projected (command/cmd, url + derived domain,
72
+ path/file_path + action inferred from the tool-name verb,
73
+ session_id, est_cost); everything else is ignored.
74
+ """
75
+ params: dict = {}
76
+ if isinstance(tool_input, str):
77
+ params["command"] = tool_input
78
+ elif isinstance(tool_input, dict):
79
+ if "command" in tool_input or "cmd" in tool_input:
80
+ params["command"] = tool_input.get(
81
+ "command", tool_input.get("cmd", "")
82
+ )
83
+ if "url" in tool_input:
84
+ params["url"] = tool_input["url"]
85
+ domain = domain_from_url(tool_input["url"])
86
+ if domain:
87
+ params["domain"] = domain
88
+ if "path" in tool_input or "file_path" in tool_input:
89
+ params["path"] = tool_input.get(
90
+ "path", tool_input.get("file_path", "")
91
+ )
92
+ action = infer_path_action(tool_name)
93
+ if action:
94
+ params["action"] = action
95
+ if "session_id" in tool_input:
96
+ params["session_id"] = tool_input["session_id"]
97
+ if "est_cost" in tool_input:
98
+ params["est_cost"] = tool_input["est_cost"]
99
+ 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.6.0"
44
+ SDK_VERSION = "1.0.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.6.0
3
+ Version: 1.0.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
@@ -11,6 +14,7 @@ agentguardproxy.egg-info/SOURCES.txt
11
14
  agentguardproxy.egg-info/dependency_links.txt
12
15
  agentguardproxy.egg-info/requires.txt
13
16
  agentguardproxy.egg-info/top_level.txt
17
+ tests/test_action_parity.py
14
18
  tests/test_adapters.py
15
19
  tests/test_adapters_extended.py
16
20
  tests/test_browseruse.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentguardproxy"
7
- version = "0.6.0"
7
+ version = "1.0.0"
8
8
  description = "Python SDK for AgentGuard — the firewall for AI agents"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -0,0 +1,113 @@
1
+ """Filesystem-action inference parity: Python SDK <-> Go transports.
2
+
3
+ The Python adapters (infer_path_action in agentguard.adapters._common) and
4
+ the Go transports (gateclient.InferFilesystemAction) must map a tool name to
5
+ the SAME filesystem `action` ("read"/"write"/"delete"/""), otherwise an
6
+ `action:`-keyed filesystem policy rule fires on one transport and not the
7
+ other for the same tool call.
8
+
9
+ The table below is the SAME (tool_name -> action) table pinned on the Go
10
+ side in pkg/internal/gateclient/gateclient_test.go (TestInferFilesystemAction).
11
+ When you add or change a verb in one place, change it in the other and update
12
+ BOTH tables — this test and that Go test are the tripwire.
13
+
14
+ This module imports ONLY agentguard.adapters._common, so it runs without
15
+ langchain / crewai / mcp (or any framework dependency) installed.
16
+ """
17
+
18
+ from agentguard.adapters._common import infer_path_action
19
+
20
+
21
+ # Mirror of gateclient_test.go's TestInferFilesystemAction table. Keep in sync.
22
+ GO_PARITY_TABLE = {
23
+ "read_file": "read",
24
+ "list_files": "read",
25
+ "get_file": "read",
26
+ "stat_file": "read",
27
+ "cat": "read",
28
+ "find_files": "read",
29
+ "glob": "read",
30
+ "write_file": "write",
31
+ "edit_file": "write",
32
+ "create_dir": "write",
33
+ "append_file": "write",
34
+ "delete_file": "delete",
35
+ "remove_dir": "delete",
36
+ "unlink": "delete",
37
+ "unknown": "",
38
+ }
39
+
40
+ # Extra regression cases for verbs the substring implementation used to miss
41
+ # (edit/append/copy/move/unlink/rm*/list/stat/cat/find/glob). These are the
42
+ # tool names that previously sent NO action through the Python SDK while the
43
+ # Go transports DID send one.
44
+ REGRESSION_TABLE = {
45
+ "edit_file": "write",
46
+ "append_file": "write",
47
+ "copy_file": "write",
48
+ "move_file": "write",
49
+ "unlink": "delete",
50
+ "rmdir": "delete", # "rm" prefix
51
+ "rm": "delete",
52
+ "list_files": "read",
53
+ "stat_file": "read",
54
+ "cat": "read",
55
+ "find_files": "read",
56
+ "glob": "read",
57
+ }
58
+
59
+ # False-positive guards: substring matching (the old behavior) wrongly mapped
60
+ # these; prefix matching (Go's HasPrefix, our str.startswith) does not.
61
+ FALSE_POSITIVE_TABLE = {
62
+ "set_target": "", # contains "get" but does not START with it
63
+ "undelete_x": "", # contains "delete" but does not START with it
64
+ "forget": "", # contains "get"
65
+ "overwrite": "", # contains "write"
66
+ }
67
+
68
+
69
+ def test_go_parity_table():
70
+ """Every pinned Go case maps identically in Python."""
71
+ for tool_name, expected in GO_PARITY_TABLE.items():
72
+ assert infer_path_action(tool_name) == expected, (
73
+ f"infer_path_action({tool_name!r}) diverged from Go parity table"
74
+ )
75
+
76
+
77
+ def test_regression_cases_previously_missed_by_substring():
78
+ for tool_name, expected in REGRESSION_TABLE.items():
79
+ assert infer_path_action(tool_name) == expected, (
80
+ f"infer_path_action({tool_name!r}) regressed"
81
+ )
82
+
83
+
84
+ def test_false_positive_guards():
85
+ for tool_name, expected in FALSE_POSITIVE_TABLE.items():
86
+ assert infer_path_action(tool_name) == expected, (
87
+ f"infer_path_action({tool_name!r}) should be {expected!r} "
88
+ f"(prefix, not substring, matching)"
89
+ )
90
+
91
+
92
+ def test_non_string_input_is_empty():
93
+ for bad in (None, 123, ["read_file"], {"name": "read_file"}, object()):
94
+ assert infer_path_action(bad) == ""
95
+
96
+
97
+ def test_case_insensitive():
98
+ # HasPrefix is applied to strings.ToLower(toolName) on the Go side; we
99
+ # lowercase too, so mixed case still resolves.
100
+ assert infer_path_action("READ_File") == "read"
101
+ assert infer_path_action("Write_Thing") == "write"
102
+ assert infer_path_action("DELETE_it") == "delete"
103
+
104
+
105
+ if __name__ == "__main__":
106
+ # Allow a framework-free smoke run without pytest installed.
107
+ for table in (GO_PARITY_TABLE, REGRESSION_TABLE, FALSE_POSITIVE_TABLE):
108
+ for name, want in table.items():
109
+ got = infer_path_action(name)
110
+ assert got == want, f"{name!r}: got {got!r}, want {want!r}"
111
+ for bad in (None, 123, ["x"], {"a": 1}):
112
+ assert infer_path_action(bad) == ""
113
+ print("action-parity smoke OK")
@@ -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