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.
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/PKG-INFO +1 -1
- agentguardproxy-0.9.0/agentguard/__init__.py +89 -0
- agentguardproxy-0.9.0/agentguard/adapters/_common.py +73 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/agentguard/adapters/crewai.py +2 -26
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/agentguard/adapters/langchain.py +45 -44
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/agentguard/adapters/mcp.py +12 -33
- agentguardproxy-0.5.2/agentguard/__init__.py → agentguardproxy-0.9.0/agentguard/core.py +7 -142
- agentguardproxy-0.9.0/agentguard/decorators.py +138 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/agentguardproxy.egg-info/PKG-INFO +1 -1
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/agentguardproxy.egg-info/SOURCES.txt +3 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/pyproject.toml +1 -1
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_langchain.py +91 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/README.md +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/agentguard/adapters/__init__.py +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/agentguard/adapters/browseruse.py +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/agentguardproxy.egg-info/dependency_links.txt +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/agentguardproxy.egg-info/requires.txt +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/agentguardproxy.egg-info/top_level.txt +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/setup.cfg +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_adapters.py +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_adapters_extended.py +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_browseruse.py +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_crewai.py +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_decorator.py +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_end_to_end_real_server.py +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_guard.py +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_integration.py +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_mcp.py +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_mcp_fuzz.py +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_mcp_gateway.py +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_polling_jitter.py +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_redactor_property.py +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_sdk_integration.py +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_sdk_polish.py +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_tenant_routing.py +0 -0
- {agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/tests/test_wire_format.py +0 -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
|
-
|
|
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
|
|
28
|
-
|
|
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
|
|
50
|
-
|
|
49
|
+
Stream gating
|
|
50
|
+
=============
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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.
|
|
425
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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,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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentguardproxy-0.5.2 → agentguardproxy-0.9.0}/agentguardproxy.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|