agentguard-oss 0.4.1__py3-none-any.whl
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.
- agentguard/__init__.py +41 -0
- agentguard/__main__.py +3 -0
- agentguard/adapters/__init__.py +0 -0
- agentguard/adapters/_common.py +54 -0
- agentguard/adapters/adk.py +23 -0
- agentguard/adapters/langchain.py +31 -0
- agentguard/adapters/mcp.py +5 -0
- agentguard/adapters/openai_agents.py +29 -0
- agentguard/adapters/python.py +3 -0
- agentguard/approval/__init__.py +16 -0
- agentguard/approval/base.py +49 -0
- agentguard/approval/cli.py +47 -0
- agentguard/approval/queue.py +90 -0
- agentguard/approval/slack.py +174 -0
- agentguard/approval/store.py +361 -0
- agentguard/approval/webhook.py +83 -0
- agentguard/audit/__init__.py +0 -0
- agentguard/audit/chain.py +36 -0
- agentguard/audit/export.py +121 -0
- agentguard/audit/logger.py +108 -0
- agentguard/audit/reader.py +42 -0
- agentguard/audit/report.py +110 -0
- agentguard/audit/segments.py +46 -0
- agentguard/bench/__init__.py +1 -0
- agentguard/bench/holdout_cases.yaml +42 -0
- agentguard/bench/judge.py +203 -0
- agentguard/bench/judge_cases.yaml +83 -0
- agentguard/cli/__init__.py +0 -0
- agentguard/cli/main.py +395 -0
- agentguard/core/__init__.py +0 -0
- agentguard/core/action.py +37 -0
- agentguard/core/context.py +11 -0
- agentguard/core/decision.py +40 -0
- agentguard/core/guard.py +598 -0
- agentguard/core/ratelimit.py +45 -0
- agentguard/dashboard/__init__.py +1 -0
- agentguard/dashboard/app.py +317 -0
- agentguard/dashboard/static/app.css +147 -0
- agentguard/dashboard/static/app.js +312 -0
- agentguard/dashboard/static/index.html +110 -0
- agentguard/demo/__init__.py +0 -0
- agentguard/demo/gemma_agent.py +106 -0
- agentguard/demo/scenario.py +169 -0
- agentguard/execution/__init__.py +13 -0
- agentguard/execution/container.py +157 -0
- agentguard/execution/egress.py +191 -0
- agentguard/execution/executor.py +9 -0
- agentguard/execution/filesystem.py +59 -0
- agentguard/execution/network.py +42 -0
- agentguard/execution/pinning.py +59 -0
- agentguard/execution/shell.py +149 -0
- agentguard/execution/verification.py +38 -0
- agentguard/interceptors/__init__.py +0 -0
- agentguard/interceptors/middleware.py +11 -0
- agentguard/policy/__init__.py +0 -0
- agentguard/policy/engine.py +20 -0
- agentguard/policy/explain.py +82 -0
- agentguard/policy/loader.py +38 -0
- agentguard/policy/matcher.py +63 -0
- agentguard/policy/schema.py +121 -0
- agentguard/py.typed +0 -0
- agentguard/risk/__init__.py +0 -0
- agentguard/risk/commands.py +450 -0
- agentguard/risk/data/gitleaks-LICENSE.txt +21 -0
- agentguard/risk/data/gitleaks.toml +3213 -0
- agentguard/risk/gemma_judge.py +106 -0
- agentguard/risk/pii.py +64 -0
- agentguard/risk/rules.py +53 -0
- agentguard/risk/rulesets.py +158 -0
- agentguard/risk/scorer.py +107 -0
- agentguard/risk/secrets.py +124 -0
- agentguard_oss-0.4.1.dist-info/METADATA +225 -0
- agentguard_oss-0.4.1.dist-info/RECORD +76 -0
- agentguard_oss-0.4.1.dist-info/WHEEL +4 -0
- agentguard_oss-0.4.1.dist-info/entry_points.txt +2 -0
- agentguard_oss-0.4.1.dist-info/licenses/LICENSE +22 -0
agentguard/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""AgentGuard's public SDK.
|
|
2
|
+
|
|
3
|
+
Public API: the names in ``__all__`` here, plus ``agentguard.approval``,
|
|
4
|
+
``agentguard.execution``, ``agentguard.adapters.*`` and
|
|
5
|
+
``agentguard.risk.gemma_judge.GemmaJudge``. Other modules are internal and may change.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
|
|
10
|
+
from agentguard.approval.base import Approval, ApprovalProvider
|
|
11
|
+
from agentguard.core.action import Action
|
|
12
|
+
from agentguard.core.decision import (
|
|
13
|
+
ApprovalPending,
|
|
14
|
+
Decision,
|
|
15
|
+
Effect,
|
|
16
|
+
GuardDenied,
|
|
17
|
+
GuardError,
|
|
18
|
+
)
|
|
19
|
+
from agentguard.core.guard import Guard
|
|
20
|
+
from agentguard.policy.explain import explain
|
|
21
|
+
from agentguard.policy.loader import load_policy
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
__version__ = version("agentguard-oss")
|
|
25
|
+
except PackageNotFoundError: # Running from a source tree without installation.
|
|
26
|
+
__version__ = "0.0.0+unknown"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Action",
|
|
30
|
+
"Approval",
|
|
31
|
+
"ApprovalPending",
|
|
32
|
+
"ApprovalProvider",
|
|
33
|
+
"Decision",
|
|
34
|
+
"Effect",
|
|
35
|
+
"Guard",
|
|
36
|
+
"GuardDenied",
|
|
37
|
+
"GuardError",
|
|
38
|
+
"__version__",
|
|
39
|
+
"explain",
|
|
40
|
+
"load_policy",
|
|
41
|
+
]
|
agentguard/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Shared adapter helpers. Adapters translate and dispatch; they make no security decisions."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import inspect
|
|
5
|
+
|
|
6
|
+
from agentguard.core.decision import ApprovalPending, GuardDenied
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def denial_result(exc: GuardDenied) -> dict:
|
|
10
|
+
"""What the model sees when AgentGuard denies a call: the reasons, so it can adapt."""
|
|
11
|
+
if isinstance(exc, ApprovalPending):
|
|
12
|
+
return {
|
|
13
|
+
"error": "Approval pending",
|
|
14
|
+
"request_id": exc.request_id,
|
|
15
|
+
"reasons": list(exc.decision.reasons),
|
|
16
|
+
"next_step": "A human must approve this. Continue with other work and retry "
|
|
17
|
+
"the same call later.",
|
|
18
|
+
}
|
|
19
|
+
return {"error": "Denied by AgentGuard", "reasons": list(exc.decision.reasons)}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def returning_denials(guarded, on_denied=denial_result):
|
|
23
|
+
"""Wrap a guarded tool so denials become a result instead of an exception.
|
|
24
|
+
|
|
25
|
+
Only GuardDenied is converted. Other GuardErrors (a halted session, failed execution)
|
|
26
|
+
still raise, so frameworks surface them as tool failures.
|
|
27
|
+
"""
|
|
28
|
+
if inspect.iscoroutinefunction(guarded):
|
|
29
|
+
|
|
30
|
+
@functools.wraps(guarded)
|
|
31
|
+
async def wrapper(*args, **kwargs):
|
|
32
|
+
try:
|
|
33
|
+
return await guarded(*args, **kwargs)
|
|
34
|
+
except GuardDenied as exc:
|
|
35
|
+
return on_denied(exc)
|
|
36
|
+
else:
|
|
37
|
+
|
|
38
|
+
@functools.wraps(guarded)
|
|
39
|
+
def wrapper(*args, **kwargs):
|
|
40
|
+
try:
|
|
41
|
+
return guarded(*args, **kwargs)
|
|
42
|
+
except GuardDenied as exc:
|
|
43
|
+
return on_denied(exc)
|
|
44
|
+
|
|
45
|
+
# The return type changes on denial; frameworks infer only the arguments from this.
|
|
46
|
+
wrapper.__signature__ = inspect.signature(guarded).replace(
|
|
47
|
+
return_annotation=inspect.Signature.empty
|
|
48
|
+
)
|
|
49
|
+
wrapper.__annotations__ = {k: v for k, v in guarded.__annotations__.items() if k != "return"}
|
|
50
|
+
return wrapper
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def describe(function, description):
|
|
54
|
+
return description or inspect.getdoc(function) or f"{function.__name__} (guarded by AgentGuard)"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Google Agent Development Kit (ADK) tools backed by AgentGuard.
|
|
2
|
+
|
|
3
|
+
Requires ``agentguard-oss[adk]``. Pass only the returned FunctionTool to ``Agent(tools=[...])``.
|
|
4
|
+
ADK builds the declaration from the function's signature and docstring.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from google.adk.tools import FunctionTool
|
|
8
|
+
|
|
9
|
+
from agentguard.adapters._common import returning_denials
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def from_guarded(guarded, *, return_denials=True) -> FunctionTool:
|
|
13
|
+
"""Wrap a function already decorated with ``@guard.tool``."""
|
|
14
|
+
return FunctionTool(returning_denials(guarded) if return_denials else guarded)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def guarded_tool(guard, function, *, capability, name=None, return_denials=True, **options):
|
|
18
|
+
"""Register ``function`` with ``guard`` and return it as an ADK FunctionTool.
|
|
19
|
+
|
|
20
|
+
``options`` are passed to ``guard.tool`` (executor, verifier, path_arg, ...).
|
|
21
|
+
"""
|
|
22
|
+
guarded = guard.tool(capability=capability, name=name, **options)(function)
|
|
23
|
+
return from_guarded(guarded, return_denials=return_denials)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""LangChain and LangGraph tools backed by AgentGuard. Requires ``agentguard-oss[langchain]``.
|
|
2
|
+
|
|
3
|
+
The returned StructuredTool works anywhere LangChain tools do, including LangGraph's
|
|
4
|
+
ToolNode and prebuilt agents. Pass only the returned tool to the agent.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import inspect
|
|
8
|
+
|
|
9
|
+
from langchain_core.tools import StructuredTool
|
|
10
|
+
|
|
11
|
+
from agentguard.adapters._common import describe, returning_denials
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def from_guarded(guarded, *, name=None, description=None, return_denials=True):
|
|
15
|
+
"""Wrap a function already decorated with ``@guard.tool``."""
|
|
16
|
+
func = returning_denials(guarded) if return_denials else guarded
|
|
17
|
+
options = {"name": name or guarded.__name__, "description": describe(guarded, description)}
|
|
18
|
+
if inspect.iscoroutinefunction(guarded):
|
|
19
|
+
return StructuredTool.from_function(coroutine=func, **options)
|
|
20
|
+
return StructuredTool.from_function(func=func, **options)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def guarded_tool(
|
|
24
|
+
guard, function, *, capability, name=None, description=None, return_denials=True, **options
|
|
25
|
+
):
|
|
26
|
+
"""Register ``function`` with ``guard`` and return it as a LangChain StructuredTool.
|
|
27
|
+
|
|
28
|
+
``options`` are passed to ``guard.tool`` (executor, verifier, path_arg, ...).
|
|
29
|
+
"""
|
|
30
|
+
guarded = guard.tool(capability=capability, name=name, **options)(function)
|
|
31
|
+
return from_guarded(guarded, description=description, return_denials=return_denials)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""OpenAI Agents SDK tools backed by AgentGuard. Requires ``agentguard-oss[openai-agents]``.
|
|
2
|
+
|
|
3
|
+
Pass only the returned FunctionTool to ``Agent(tools=[...])``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from agents import FunctionTool, function_tool
|
|
7
|
+
|
|
8
|
+
from agentguard.adapters._common import describe, returning_denials
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def from_guarded(guarded, *, name=None, description=None, return_denials=True) -> FunctionTool:
|
|
12
|
+
"""Wrap a function already decorated with ``@guard.tool``."""
|
|
13
|
+
func = returning_denials(guarded) if return_denials else guarded
|
|
14
|
+
return function_tool(
|
|
15
|
+
func,
|
|
16
|
+
name_override=name or guarded.__name__,
|
|
17
|
+
description_override=describe(guarded, description),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def guarded_tool(
|
|
22
|
+
guard, function, *, capability, name=None, description=None, return_denials=True, **options
|
|
23
|
+
) -> FunctionTool:
|
|
24
|
+
"""Register ``function`` with ``guard`` and return it as an Agents SDK FunctionTool.
|
|
25
|
+
|
|
26
|
+
``options`` are passed to ``guard.tool`` (executor, verifier, path_arg, ...).
|
|
27
|
+
"""
|
|
28
|
+
guarded = guard.tool(capability=capability, name=name, **options)(function)
|
|
29
|
+
return from_guarded(guarded, description=description, return_denials=return_denials)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from agentguard.approval.base import Approval, ApprovalProvider
|
|
2
|
+
from agentguard.approval.cli import CLIApproval
|
|
3
|
+
from agentguard.approval.queue import QueueApproval
|
|
4
|
+
from agentguard.approval.slack import SlackNotifier
|
|
5
|
+
from agentguard.approval.store import ApprovalStore
|
|
6
|
+
from agentguard.approval.webhook import WebhookNotifier
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Approval",
|
|
10
|
+
"ApprovalProvider",
|
|
11
|
+
"ApprovalStore",
|
|
12
|
+
"CLIApproval",
|
|
13
|
+
"QueueApproval",
|
|
14
|
+
"SlackNotifier",
|
|
15
|
+
"WebhookNotifier",
|
|
16
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from queue import Empty, Queue
|
|
3
|
+
from threading import Thread
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from agentguard.core.action import Action
|
|
7
|
+
from agentguard.core.decision import Decision
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Approval:
|
|
12
|
+
approved: bool
|
|
13
|
+
approved_by: str = "unknown"
|
|
14
|
+
strong: bool = False
|
|
15
|
+
# Set when the request is queued for a human instead of answered now.
|
|
16
|
+
pending_id: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ApprovalProvider(Protocol):
|
|
20
|
+
def request(self, action: Action, decision: Decision) -> Approval: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def request_bounded(provider, action, decision, timeout: float) -> Approval:
|
|
24
|
+
"""Late responses never authorize execution. Providers are trusted host code."""
|
|
25
|
+
queue = Queue(maxsize=1)
|
|
26
|
+
|
|
27
|
+
def request():
|
|
28
|
+
try:
|
|
29
|
+
queue.put(provider.request(action.model_copy(deep=True), decision))
|
|
30
|
+
except Exception:
|
|
31
|
+
queue.put(Approval(False))
|
|
32
|
+
|
|
33
|
+
Thread(target=request, daemon=True).start()
|
|
34
|
+
try:
|
|
35
|
+
answer = queue.get(timeout=timeout)
|
|
36
|
+
if not isinstance(answer, Approval) or type(answer.approved) is not bool:
|
|
37
|
+
return Approval(False)
|
|
38
|
+
if answer.pending_id is not None:
|
|
39
|
+
# Queued for a human: valid only as a non-approval carrying a request ID.
|
|
40
|
+
if answer.approved or not isinstance(answer.pending_id, str):
|
|
41
|
+
return Approval(False)
|
|
42
|
+
return Approval(False, pending_id=answer.pending_id)
|
|
43
|
+
if answer.approved and (not answer.approved_by or answer.approved_by == "unknown"):
|
|
44
|
+
return Approval(False)
|
|
45
|
+
if decision.strong_approval and answer.strong is not True:
|
|
46
|
+
return Approval(False)
|
|
47
|
+
return answer
|
|
48
|
+
except Empty:
|
|
49
|
+
return Approval(False)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import getpass
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import select
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from agentguard.approval.base import Approval
|
|
10
|
+
from agentguard.risk.secrets import redact
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CLIApproval:
|
|
14
|
+
"""Approve one action using a terminal. No session-wide grants or reauthentication."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, timeout: float = 30):
|
|
17
|
+
self.timeout = timeout
|
|
18
|
+
self._lock = threading.Lock()
|
|
19
|
+
|
|
20
|
+
def request(self, action, decision):
|
|
21
|
+
if not sys.stdin.isatty() or decision.strong_approval:
|
|
22
|
+
return Approval(False)
|
|
23
|
+
if not self._lock.acquire(blocking=False):
|
|
24
|
+
return Approval(False)
|
|
25
|
+
try:
|
|
26
|
+
print("\nAGENTGUARD APPROVAL REQUEST", file=sys.stderr)
|
|
27
|
+
print(json.dumps(redact(action.model_dump()), indent=2), file=sys.stderr)
|
|
28
|
+
print(f"Risk: {decision.risk_score}/100", file=sys.stderr)
|
|
29
|
+
print("; ".join(redact(list(decision.reasons))), file=sys.stderr)
|
|
30
|
+
print("[A] Approve once / [R] Reject: ", end="", file=sys.stderr, flush=True)
|
|
31
|
+
deadline = time.monotonic() + self.timeout
|
|
32
|
+
answer = ""
|
|
33
|
+
if os.name == "nt":
|
|
34
|
+
import msvcrt
|
|
35
|
+
|
|
36
|
+
while msvcrt.kbhit():
|
|
37
|
+
msvcrt.getwch()
|
|
38
|
+
while time.monotonic() < deadline:
|
|
39
|
+
if msvcrt.kbhit():
|
|
40
|
+
answer = msvcrt.getwch().lower()
|
|
41
|
+
break
|
|
42
|
+
time.sleep(0.05)
|
|
43
|
+
elif select.select([sys.stdin], [], [], max(0, deadline - time.monotonic()))[0]:
|
|
44
|
+
answer = sys.stdin.readline().strip().lower()
|
|
45
|
+
return Approval(answer == "a", getpass.getuser())
|
|
46
|
+
finally:
|
|
47
|
+
self._lock.release()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Approval through a shared queue: dashboard, API, Slack or webhook approvers."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from agentguard.approval.base import Approval
|
|
9
|
+
from agentguard.approval.store import ApprovalStore
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class QueueApproval:
|
|
15
|
+
"""Ask humans through an ApprovalStore without holding the agent hostage.
|
|
16
|
+
|
|
17
|
+
- An active grant for the call (approved earlier, or "allow this tool for 10 minutes")
|
|
18
|
+
approves immediately.
|
|
19
|
+
- Otherwise a request is queued (an identical pending call reuses it) and notifiers run.
|
|
20
|
+
- The provider waits up to ``wait`` seconds for a decision. If none arrives, the call
|
|
21
|
+
raises ``ApprovalPending`` with the request ID. Retrying the same call after approval
|
|
22
|
+
succeeds through the grant. ``wait=0`` (default) never blocks the agent.
|
|
23
|
+
|
|
24
|
+
Keep the Guard's ``approval_timeout`` above ``wait``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, store, *, wait=0.0, request_ttl=3600.0, notifiers=(), poll_interval=0.25):
|
|
28
|
+
if wait < 0 or request_ttl <= 0:
|
|
29
|
+
raise ValueError("wait must be >= 0 and request_ttl > 0")
|
|
30
|
+
self.store = store if isinstance(store, ApprovalStore) else ApprovalStore(store)
|
|
31
|
+
self.timeout = wait
|
|
32
|
+
self.request_ttl = request_ttl
|
|
33
|
+
self.notifiers = tuple(notifiers)
|
|
34
|
+
self.poll_interval = poll_interval
|
|
35
|
+
|
|
36
|
+
def request(self, action, decision):
|
|
37
|
+
grant = self.store.use_grant(action, decision)
|
|
38
|
+
if grant:
|
|
39
|
+
return _granted(grant)
|
|
40
|
+
request, created = self.store.submit(action, decision, ttl=self.request_ttl)
|
|
41
|
+
if request["status"] == "rejected":
|
|
42
|
+
return Approval(False) # The same call was recently rejected.
|
|
43
|
+
if created and self.notifiers:
|
|
44
|
+
threading.Thread(
|
|
45
|
+
target=self._notify, args=(request,), name="agentguard-notify", daemon=True
|
|
46
|
+
).start()
|
|
47
|
+
deadline = time.monotonic() + self.timeout
|
|
48
|
+
while time.monotonic() < deadline:
|
|
49
|
+
status = (self.store.get(request["id"]) or {}).get("status")
|
|
50
|
+
if status == "approved":
|
|
51
|
+
grant = self.store.use_grant(action, decision)
|
|
52
|
+
return _granted(grant) if grant else Approval(False)
|
|
53
|
+
if status in {"rejected", "expired"}:
|
|
54
|
+
return Approval(False)
|
|
55
|
+
time.sleep(min(self.poll_interval, max(0.0, deadline - time.monotonic())))
|
|
56
|
+
return Approval(False, pending_id=request["id"])
|
|
57
|
+
|
|
58
|
+
def status(self, request_id):
|
|
59
|
+
"""pending, approved, rejected or expired (None if unknown)."""
|
|
60
|
+
request = self.store.get(request_id)
|
|
61
|
+
return request["status"] if request else None
|
|
62
|
+
|
|
63
|
+
def wait_for(self, request_id, timeout):
|
|
64
|
+
"""Block until the request is decided or ``timeout`` passes. Returns its status."""
|
|
65
|
+
deadline = time.monotonic() + timeout
|
|
66
|
+
while (status := self.status(request_id)) == "pending" and time.monotonic() < deadline:
|
|
67
|
+
time.sleep(self.poll_interval)
|
|
68
|
+
return status
|
|
69
|
+
|
|
70
|
+
async def await_decision(self, request_id, timeout):
|
|
71
|
+
"""Async ``wait_for``: lets other tasks run while a human decides."""
|
|
72
|
+
deadline = time.monotonic() + timeout
|
|
73
|
+
while (status := self.status(request_id)) == "pending" and time.monotonic() < deadline:
|
|
74
|
+
await asyncio.sleep(self.poll_interval)
|
|
75
|
+
return status
|
|
76
|
+
|
|
77
|
+
def _notify(self, request):
|
|
78
|
+
for notifier in self.notifiers:
|
|
79
|
+
try:
|
|
80
|
+
notifier.notify(request)
|
|
81
|
+
except Exception:
|
|
82
|
+
log.warning("AgentGuard approval notifier %r failed", notifier, exc_info=True)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _granted(grant):
|
|
86
|
+
return Approval(
|
|
87
|
+
True,
|
|
88
|
+
approved_by=f"{grant['approved_by']} (grant {grant['id']})",
|
|
89
|
+
strong=bool(grant["strong"]),
|
|
90
|
+
)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Slack approvals: a message with buttons, and a verified handler for button clicks.
|
|
2
|
+
|
|
3
|
+
Setup: create a Slack app with a bot token (``chat:write``), invite it to a channel, turn
|
|
4
|
+
on Interactivity with the request URL ``<dashboard>/slack/actions``, and link approvers
|
|
5
|
+
to Slack users (``agentguard approvers add NAME --slack-user U123``). The dashboard
|
|
6
|
+
verifies Slack's signing secret on every click. Strong approval is never given in Slack.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import hmac
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
from urllib.parse import parse_qs
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from agentguard.approval.webhook import summarize
|
|
18
|
+
|
|
19
|
+
ACTIONS = {
|
|
20
|
+
"agentguard_approve_once": ("once", None),
|
|
21
|
+
"agentguard_allow_tool_10": ("tool", 10),
|
|
22
|
+
"agentguard_reject": (None, None),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _text(summary):
|
|
27
|
+
reasons = "\n".join(f"• {reason}" for reason in summary["reasons"][:6]) or "• (none)"
|
|
28
|
+
arguments = json.dumps(summary["arguments"], sort_keys=True)
|
|
29
|
+
if len(arguments) > 600:
|
|
30
|
+
arguments = arguments[:600] + "…"
|
|
31
|
+
return (
|
|
32
|
+
f"*AgentGuard approval needed* (`{summary['id']}`)\n"
|
|
33
|
+
f"Agent `{summary['agent_id']}` wants `{summary['tool']}` "
|
|
34
|
+
f"({summary['capability']}) in {summary['environment']}. Risk {summary['risk']}/100.\n"
|
|
35
|
+
f"{reasons}\n```{arguments}```"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_message(request, *, interactive=True, dashboard_url=None):
|
|
40
|
+
summary = summarize(request, dashboard_url)
|
|
41
|
+
blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": _text(summary)}}]
|
|
42
|
+
buttons = []
|
|
43
|
+
if interactive and not summary["strong_required"]:
|
|
44
|
+
buttons = [
|
|
45
|
+
{
|
|
46
|
+
"type": "button",
|
|
47
|
+
"action_id": "agentguard_approve_once",
|
|
48
|
+
"text": {"type": "plain_text", "text": "Approve once"},
|
|
49
|
+
"style": "primary",
|
|
50
|
+
"value": summary["id"],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"type": "button",
|
|
54
|
+
"action_id": "agentguard_allow_tool_10",
|
|
55
|
+
"text": {"type": "plain_text", "text": "Allow this tool 10 min"},
|
|
56
|
+
"value": summary["id"],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"type": "button",
|
|
60
|
+
"action_id": "agentguard_reject",
|
|
61
|
+
"text": {"type": "plain_text", "text": "Reject"},
|
|
62
|
+
"style": "danger",
|
|
63
|
+
"value": summary["id"],
|
|
64
|
+
},
|
|
65
|
+
]
|
|
66
|
+
if summary.get("review_url"):
|
|
67
|
+
buttons.append(
|
|
68
|
+
{
|
|
69
|
+
"type": "button",
|
|
70
|
+
"action_id": "agentguard_open",
|
|
71
|
+
"text": {"type": "plain_text", "text": "Open dashboard"},
|
|
72
|
+
"url": summary["review_url"],
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
if summary["strong_required"]:
|
|
76
|
+
blocks.append(
|
|
77
|
+
{
|
|
78
|
+
"type": "context",
|
|
79
|
+
"elements": [
|
|
80
|
+
{"type": "mrkdwn", "text": "Strong approval required: use the dashboard."}
|
|
81
|
+
],
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
if buttons:
|
|
85
|
+
blocks.append({"type": "actions", "elements": buttons})
|
|
86
|
+
fallback = f"AgentGuard approval needed: {summary['tool']} ({summary['id']})"
|
|
87
|
+
return {"text": fallback, "blocks": blocks}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class SlackNotifier:
|
|
91
|
+
"""Post approval requests to Slack.
|
|
92
|
+
|
|
93
|
+
With ``bot_token`` and ``channel``: interactive buttons (needs the dashboard's
|
|
94
|
+
``/slack/actions`` endpoint). With ``webhook_url``: a message linking to the dashboard.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
*,
|
|
100
|
+
bot_token=None,
|
|
101
|
+
channel=None,
|
|
102
|
+
webhook_url=None,
|
|
103
|
+
dashboard_url=None,
|
|
104
|
+
timeout=5.0,
|
|
105
|
+
client=None,
|
|
106
|
+
):
|
|
107
|
+
if not (bot_token and channel) and not webhook_url:
|
|
108
|
+
raise ValueError("Provide bot_token and channel, or webhook_url")
|
|
109
|
+
self.bot_token, self.channel, self.webhook_url = bot_token, channel, webhook_url
|
|
110
|
+
self.dashboard_url = dashboard_url
|
|
111
|
+
self.client = client or httpx.Client(timeout=timeout, trust_env=False)
|
|
112
|
+
|
|
113
|
+
def notify(self, request):
|
|
114
|
+
if self.bot_token:
|
|
115
|
+
message = build_message(request, interactive=True, dashboard_url=self.dashboard_url)
|
|
116
|
+
response = self.client.post(
|
|
117
|
+
"https://slack.com/api/chat.postMessage",
|
|
118
|
+
headers={"Authorization": f"Bearer {self.bot_token}"},
|
|
119
|
+
json={"channel": self.channel, **message},
|
|
120
|
+
)
|
|
121
|
+
response.raise_for_status()
|
|
122
|
+
if not response.json().get("ok"):
|
|
123
|
+
raise RuntimeError(f"Slack error: {response.json().get('error')}")
|
|
124
|
+
else:
|
|
125
|
+
message = build_message(request, interactive=False, dashboard_url=self.dashboard_url)
|
|
126
|
+
self.client.post(self.webhook_url, json=message).raise_for_status()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def verify_slack_signature(signing_secret, timestamp, body, received, *, now=None):
|
|
130
|
+
"""Slack's v0 request signature, rejecting requests older than five minutes."""
|
|
131
|
+
try:
|
|
132
|
+
if abs((now or time.time()) - int(timestamp)) > 300:
|
|
133
|
+
return False
|
|
134
|
+
except (TypeError, ValueError):
|
|
135
|
+
return False
|
|
136
|
+
secret = signing_secret.encode() if isinstance(signing_secret, str) else signing_secret
|
|
137
|
+
base = b"v0:" + timestamp.encode() + b":" + body
|
|
138
|
+
expected = "v0=" + hmac.new(secret, base, hashlib.sha256).hexdigest()
|
|
139
|
+
return hmac.compare_digest(expected, received or "")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def handle_action(store, body: bytes):
|
|
143
|
+
"""Apply a verified Slack button click. Returns the text to show in Slack."""
|
|
144
|
+
payload = json.loads(parse_qs(body.decode())["payload"][0])
|
|
145
|
+
user = payload.get("user", {}).get("id")
|
|
146
|
+
approver = store.approver_for_slack(user)
|
|
147
|
+
if approver is None:
|
|
148
|
+
return "You are not an AgentGuard approver."
|
|
149
|
+
action = (payload.get("actions") or [{}])[0]
|
|
150
|
+
if action.get("action_id") not in ACTIONS:
|
|
151
|
+
return "Unknown action."
|
|
152
|
+
scope, minutes = ACTIONS[action["action_id"]]
|
|
153
|
+
try:
|
|
154
|
+
request = store.resolve(
|
|
155
|
+
action.get("value"),
|
|
156
|
+
approved=scope is not None,
|
|
157
|
+
approver=approver["name"],
|
|
158
|
+
scope=scope or "once",
|
|
159
|
+
minutes=minutes,
|
|
160
|
+
)
|
|
161
|
+
except (KeyError, ValueError, PermissionError) as exc:
|
|
162
|
+
return f"Not applied: {exc}"
|
|
163
|
+
if request["status"] == "rejected":
|
|
164
|
+
return f"Rejected by {approver['name']}."
|
|
165
|
+
detail = "for this one call" if scope == "once" else f"for {request['tool']} for {minutes} min"
|
|
166
|
+
return f"Approved by {approver['name']} {detail}."
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def post_response(response_url, text, client=None):
|
|
170
|
+
"""Replace the original Slack message with the outcome. Only Slack's own hook URLs."""
|
|
171
|
+
if not str(response_url).startswith("https://hooks.slack.com/"):
|
|
172
|
+
return
|
|
173
|
+
client = client or httpx.Client(timeout=5.0, trust_env=False)
|
|
174
|
+
client.post(response_url, json={"replace_original": True, "text": text})
|