agf-sdk 0.1.0__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.
- agf/__init__.py +37 -0
- agf/client.py +207 -0
- agf/crewai.py +128 -0
- agf/exceptions.py +57 -0
- agf/govern.py +247 -0
- agf/langchain.py +124 -0
- agf/py.typed +0 -0
- agf/sync.py +111 -0
- agf/webhook.py +87 -0
- agf_sdk-0.1.0.dist-info/METADATA +217 -0
- agf_sdk-0.1.0.dist-info/RECORD +12 -0
- agf_sdk-0.1.0.dist-info/WHEEL +4 -0
agf/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Agent Governance Foundation Python SDK."""
|
|
2
|
+
from .client import AGFClient, Agent, DecisionResult
|
|
3
|
+
from .exceptions import (
|
|
4
|
+
AGFAuthError,
|
|
5
|
+
AGFConnectionError,
|
|
6
|
+
AGFDeniedError,
|
|
7
|
+
AGFError,
|
|
8
|
+
AGFReviewRequiredError,
|
|
9
|
+
)
|
|
10
|
+
from .govern import AgentGovernance, AuthResult
|
|
11
|
+
from .sync import SyncAGFClient
|
|
12
|
+
from .webhook import AGFWebhookVerificationError, WebhookEvent, parse_event, verify_signature
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
# High-level facade (most users start here)
|
|
16
|
+
"AgentGovernance",
|
|
17
|
+
"AuthResult",
|
|
18
|
+
# Async client
|
|
19
|
+
"AGFClient",
|
|
20
|
+
"Agent",
|
|
21
|
+
"DecisionResult",
|
|
22
|
+
# Sync client
|
|
23
|
+
"SyncAGFClient",
|
|
24
|
+
# Exceptions
|
|
25
|
+
"AGFError",
|
|
26
|
+
"AGFAuthError",
|
|
27
|
+
"AGFConnectionError",
|
|
28
|
+
"AGFDeniedError",
|
|
29
|
+
"AGFReviewRequiredError",
|
|
30
|
+
# Webhook
|
|
31
|
+
"AGFWebhookVerificationError",
|
|
32
|
+
"WebhookEvent",
|
|
33
|
+
"verify_signature",
|
|
34
|
+
"parse_event",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
agf/client.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""AGF Python SDK — async client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .exceptions import AGFAuthError, AGFConnectionError, AGFDeniedError, AGFReviewRequiredError
|
|
10
|
+
|
|
11
|
+
_DEFAULT_BASE_URL = "https://api.agentgovernancefoundation.com"
|
|
12
|
+
_DEFAULT_TIMEOUT = 15.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class DecisionResult:
|
|
17
|
+
"""Returned by :meth:`AGFClient.decide` on an ALLOW decision."""
|
|
18
|
+
decision: str
|
|
19
|
+
artifact_id: str
|
|
20
|
+
trust_score: int
|
|
21
|
+
risk_score: float
|
|
22
|
+
policy_version: str
|
|
23
|
+
chain_depth: int
|
|
24
|
+
effective_scope: list[str]
|
|
25
|
+
reasoning: list[str]
|
|
26
|
+
approval_request_id: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Agent:
|
|
31
|
+
id: str
|
|
32
|
+
org_id: str
|
|
33
|
+
name: str
|
|
34
|
+
did: str
|
|
35
|
+
status: str
|
|
36
|
+
trust_score: int | None = None
|
|
37
|
+
created_at: str = ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class AGFClient:
|
|
42
|
+
"""Async AGF runtime client.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
api_key: Org-level API key from Settings → API Keys.
|
|
46
|
+
base_url: Override the default AGF runtime URL.
|
|
47
|
+
timeout: HTTP timeout in seconds.
|
|
48
|
+
|
|
49
|
+
Example::
|
|
50
|
+
|
|
51
|
+
client = AGFClient(api_key="ak_live_...")
|
|
52
|
+
result = await client.decide(
|
|
53
|
+
action_type="write:database",
|
|
54
|
+
resource="prod-customers",
|
|
55
|
+
chain=[root_jwt, agent_jwt],
|
|
56
|
+
)
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
api_key: str
|
|
60
|
+
base_url: str = _DEFAULT_BASE_URL
|
|
61
|
+
timeout: float = _DEFAULT_TIMEOUT
|
|
62
|
+
_http: httpx.AsyncClient = field(init=False, repr=False)
|
|
63
|
+
|
|
64
|
+
def __post_init__(self) -> None:
|
|
65
|
+
self._http = httpx.AsyncClient(
|
|
66
|
+
base_url=self.base_url.rstrip("/"),
|
|
67
|
+
headers={
|
|
68
|
+
"X-AGF-Key": self.api_key,
|
|
69
|
+
"Content-Type": "application/json",
|
|
70
|
+
"User-Agent": "agf-sdk/0.1.0 python",
|
|
71
|
+
},
|
|
72
|
+
timeout=self._timeout_obj,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def _timeout_obj(self) -> httpx.Timeout:
|
|
77
|
+
return httpx.Timeout(self.timeout)
|
|
78
|
+
|
|
79
|
+
async def __aenter__(self) -> "AGFClient":
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
83
|
+
await self.aclose()
|
|
84
|
+
|
|
85
|
+
async def aclose(self) -> None:
|
|
86
|
+
await self._http.aclose()
|
|
87
|
+
|
|
88
|
+
async def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
89
|
+
try:
|
|
90
|
+
resp = await self._http.request(method, path, **kwargs)
|
|
91
|
+
except httpx.ConnectError as exc:
|
|
92
|
+
raise AGFConnectionError(f"Cannot connect to AGF runtime at {self.base_url}: {exc}") from exc
|
|
93
|
+
except httpx.TimeoutException as exc:
|
|
94
|
+
raise AGFConnectionError(f"AGF runtime request timed out: {exc}") from exc
|
|
95
|
+
|
|
96
|
+
if resp.status_code == 401:
|
|
97
|
+
raise AGFAuthError("Invalid API key or suspended organisation.")
|
|
98
|
+
if resp.status_code == 403:
|
|
99
|
+
raise AGFAuthError("Insufficient permissions.")
|
|
100
|
+
|
|
101
|
+
resp.raise_for_status()
|
|
102
|
+
return resp.json() # type: ignore[no-any-return]
|
|
103
|
+
|
|
104
|
+
async def decide(
|
|
105
|
+
self,
|
|
106
|
+
action_type: str,
|
|
107
|
+
resource: str,
|
|
108
|
+
*,
|
|
109
|
+
chain: list[str] | None = None,
|
|
110
|
+
audience: str = "agf",
|
|
111
|
+
context: dict[str, Any] | None = None,
|
|
112
|
+
policy_version: str | None = None,
|
|
113
|
+
) -> DecisionResult:
|
|
114
|
+
"""Evaluate an action against the AGF policy engine.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
AGFDeniedError: The PDP issued a DENY decision.
|
|
118
|
+
AGFReviewRequiredError: The action needs human review (HITL).
|
|
119
|
+
AGFConnectionError: Cannot reach the AGF runtime.
|
|
120
|
+
AGFAuthError: API key is invalid.
|
|
121
|
+
"""
|
|
122
|
+
body: dict[str, Any] = {
|
|
123
|
+
"chain": chain or [],
|
|
124
|
+
"action": {"type": action_type, "resource": resource},
|
|
125
|
+
"audience": audience,
|
|
126
|
+
}
|
|
127
|
+
if context:
|
|
128
|
+
body["context"] = context
|
|
129
|
+
if policy_version:
|
|
130
|
+
body["policy_version"] = policy_version
|
|
131
|
+
|
|
132
|
+
data = await self._request("POST", "/v1/decide", json=body)
|
|
133
|
+
decision_data = data.get("data", data)
|
|
134
|
+
decision = decision_data.get("decision", "DENY")
|
|
135
|
+
artifact_id = decision_data.get("artifact_id", "")
|
|
136
|
+
|
|
137
|
+
if decision == "DENY":
|
|
138
|
+
raise AGFDeniedError(
|
|
139
|
+
f"Action '{action_type}' on '{resource}' was denied by policy.",
|
|
140
|
+
artifact_id=artifact_id,
|
|
141
|
+
risk_score=decision_data.get("risk_score", 0.0),
|
|
142
|
+
reasoning=decision_data.get("reasoning", []),
|
|
143
|
+
)
|
|
144
|
+
if decision == "REVIEW_REQUIRED":
|
|
145
|
+
raise AGFReviewRequiredError(
|
|
146
|
+
f"Action '{action_type}' on '{resource}' requires human approval.",
|
|
147
|
+
approval_request_id=decision_data.get("approval_request_id") or "",
|
|
148
|
+
artifact_id=artifact_id,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return DecisionResult(
|
|
152
|
+
decision=decision,
|
|
153
|
+
artifact_id=artifact_id,
|
|
154
|
+
trust_score=decision_data.get("trust_score", 0),
|
|
155
|
+
risk_score=decision_data.get("risk_score", 0.0),
|
|
156
|
+
policy_version=decision_data.get("policy_version", ""),
|
|
157
|
+
chain_depth=decision_data.get("chain_depth", 0),
|
|
158
|
+
effective_scope=decision_data.get("effective_scope", []),
|
|
159
|
+
reasoning=decision_data.get("reasoning", []),
|
|
160
|
+
approval_request_id=decision_data.get("approval_request_id"),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
async def list_agents(
|
|
164
|
+
self,
|
|
165
|
+
*,
|
|
166
|
+
status: str | None = None,
|
|
167
|
+
page: int = 1,
|
|
168
|
+
per_page: int = 50,
|
|
169
|
+
) -> list[Agent]:
|
|
170
|
+
params: dict[str, Any] = {"page": page, "per_page": per_page}
|
|
171
|
+
if status:
|
|
172
|
+
params["status"] = status
|
|
173
|
+
data = await self._request("GET", "/v1/agents", params=params)
|
|
174
|
+
items = data.get("items", data) if isinstance(data, dict) else data
|
|
175
|
+
return [
|
|
176
|
+
Agent(
|
|
177
|
+
id=a["id"],
|
|
178
|
+
org_id=a.get("org_id", ""),
|
|
179
|
+
name=a.get("name", ""),
|
|
180
|
+
did=a.get("did", ""),
|
|
181
|
+
status=a.get("status", "active"),
|
|
182
|
+
trust_score=a.get("trust_score"),
|
|
183
|
+
created_at=a.get("created_at", ""),
|
|
184
|
+
)
|
|
185
|
+
for a in items
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
async def register_agent(
|
|
189
|
+
self,
|
|
190
|
+
name: str,
|
|
191
|
+
did: str,
|
|
192
|
+
public_key_pem: str,
|
|
193
|
+
metadata: dict[str, Any] | None = None,
|
|
194
|
+
) -> Agent:
|
|
195
|
+
body: dict[str, Any] = {"name": name, "did": did, "public_key_pem": public_key_pem}
|
|
196
|
+
if metadata:
|
|
197
|
+
body["metadata"] = metadata
|
|
198
|
+
a = await self._request("POST", "/v1/agents", json=body)
|
|
199
|
+
return Agent(
|
|
200
|
+
id=a["id"],
|
|
201
|
+
org_id=a.get("org_id", ""),
|
|
202
|
+
name=a.get("name", name),
|
|
203
|
+
did=a.get("did", did),
|
|
204
|
+
status=a.get("status", "active"),
|
|
205
|
+
trust_score=a.get("trust_score"),
|
|
206
|
+
created_at=a.get("created_at", ""),
|
|
207
|
+
)
|
agf/crewai.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""CrewAI integration — AGFCrewAITool.
|
|
2
|
+
|
|
3
|
+
Install extra: pip install agf-sdk[crewai]
|
|
4
|
+
|
|
5
|
+
In CrewAI, policy enforcement happens at the **Tool** level — the ``Task``
|
|
6
|
+
object has no direct intercept point since execution is driven by the ``Agent``
|
|
7
|
+
runner. Wrap individual tools before assigning them to a CrewAI Agent.
|
|
8
|
+
|
|
9
|
+
Usage::
|
|
10
|
+
|
|
11
|
+
from crewai.tools import BaseTool as CrewBaseTool
|
|
12
|
+
from agf.crewai import AGFCrewAITool
|
|
13
|
+
from agf import AGFClient
|
|
14
|
+
|
|
15
|
+
client = AGFClient(api_key="ak_live_...")
|
|
16
|
+
|
|
17
|
+
class MyDBTool(CrewBaseTool):
|
|
18
|
+
name: str = "database_query"
|
|
19
|
+
description: str = "Query the production database"
|
|
20
|
+
|
|
21
|
+
def _run(self, query: str) -> str:
|
|
22
|
+
return db.execute(query)
|
|
23
|
+
|
|
24
|
+
guarded = AGFCrewAITool(
|
|
25
|
+
tool=MyDBTool(),
|
|
26
|
+
client=client,
|
|
27
|
+
agent_id="did:agf:crew-researcher",
|
|
28
|
+
action_type="query:database",
|
|
29
|
+
resource="prod-db",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
crew_agent = Agent(tools=[guarded], ...)
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
from typing import Any, TYPE_CHECKING
|
|
38
|
+
|
|
39
|
+
from .exceptions import AGFDeniedError, AGFReviewRequiredError
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from .client import AGFClient
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
from crewai.tools import BaseTool as CrewBaseTool # type: ignore[import]
|
|
46
|
+
except ImportError:
|
|
47
|
+
try:
|
|
48
|
+
# Older CrewAI versions re-exported LangChain's BaseTool
|
|
49
|
+
from langchain_core.tools import BaseTool as CrewBaseTool # type: ignore[import]
|
|
50
|
+
except ImportError as exc:
|
|
51
|
+
raise ImportError(
|
|
52
|
+
"crewai is required for AGFCrewAITool. "
|
|
53
|
+
"Install with: pip install agf-sdk[crewai]"
|
|
54
|
+
) from exc
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AGFCrewAITool(CrewBaseTool):
|
|
58
|
+
"""A CrewAI tool wrapper that enforces AGF policy before execution.
|
|
59
|
+
|
|
60
|
+
CrewAI agents call ``_run()`` on tools during task execution. This wrapper
|
|
61
|
+
intercepts that call, runs an AGF policy check, and only invokes the real
|
|
62
|
+
tool if the decision is ALLOW.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
tool: The underlying CrewAI (or LangChain) ``BaseTool`` to guard.
|
|
66
|
+
client: An initialised :class:`~agf.client.AGFClient`.
|
|
67
|
+
agent_id: DID or identifier for the acting agent.
|
|
68
|
+
action_type: AGF action type, e.g. ``"query:database"``.
|
|
69
|
+
Defaults to ``"tool:<tool_name>"``.
|
|
70
|
+
resource: AGF resource string. Defaults to the tool name.
|
|
71
|
+
chain: JWT delegation chain passed to ``/v1/decide``.
|
|
72
|
+
audience: AGF audience field (default ``"agf"``).
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
AGFDeniedError: Decision was DENY.
|
|
76
|
+
AGFReviewRequiredError: Decision was REVIEW_REQUIRED.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
tool: Any
|
|
80
|
+
client: Any
|
|
81
|
+
agent_id: str = ""
|
|
82
|
+
action_type: str = ""
|
|
83
|
+
resource: str = ""
|
|
84
|
+
chain: list[str] = []
|
|
85
|
+
audience: str = "agf"
|
|
86
|
+
|
|
87
|
+
class Config:
|
|
88
|
+
arbitrary_types_allowed = True
|
|
89
|
+
|
|
90
|
+
def model_post_init(self, __context: Any) -> None:
|
|
91
|
+
if not self.name:
|
|
92
|
+
object.__setattr__(self, "name", f"agf_guarded_{self.tool.name}")
|
|
93
|
+
if not self.description:
|
|
94
|
+
object.__setattr__(self, "description", self.tool.description)
|
|
95
|
+
if not self.action_type:
|
|
96
|
+
object.__setattr__(self, "action_type", f"tool:{self.tool.name}")
|
|
97
|
+
if not self.resource:
|
|
98
|
+
object.__setattr__(self, "resource", self.tool.name)
|
|
99
|
+
|
|
100
|
+
def _run(self, *args: Any, **kwargs: Any) -> Any:
|
|
101
|
+
"""Synchronous execution with AGF policy gate."""
|
|
102
|
+
self._enforce_sync()
|
|
103
|
+
return self.tool._run(*args, **kwargs)
|
|
104
|
+
|
|
105
|
+
def _enforce_sync(self) -> None:
|
|
106
|
+
"""Run the async policy check synchronously, handling nested loops."""
|
|
107
|
+
async def _check() -> None:
|
|
108
|
+
await self.client.decide(
|
|
109
|
+
self.action_type,
|
|
110
|
+
self.resource,
|
|
111
|
+
chain=self.chain or None,
|
|
112
|
+
audience=self.audience,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
loop = asyncio.get_running_loop()
|
|
117
|
+
except RuntimeError:
|
|
118
|
+
loop = None
|
|
119
|
+
|
|
120
|
+
if loop and loop.is_running():
|
|
121
|
+
try:
|
|
122
|
+
import nest_asyncio # type: ignore[import]
|
|
123
|
+
nest_asyncio.apply()
|
|
124
|
+
except ImportError:
|
|
125
|
+
pass
|
|
126
|
+
loop.run_until_complete(loop.create_task(_check()))
|
|
127
|
+
else:
|
|
128
|
+
asyncio.run(_check())
|
agf/exceptions.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""AGF SDK exceptions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AGFError(Exception):
|
|
6
|
+
"""Base exception for all AGF SDK errors."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AGFConnectionError(AGFError):
|
|
10
|
+
"""Failed to reach the AGF runtime API."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AGFAuthError(AGFError):
|
|
14
|
+
"""API key is invalid or the org is suspended."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AGFDeniedError(AGFError):
|
|
18
|
+
"""The PDP denied the requested action.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
artifact_id: Signed audit artifact ID for this decision.
|
|
22
|
+
risk_score: Risk score at time of decision (0.0–1.0).
|
|
23
|
+
reasoning: List of reasons from the policy engine.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
message: str,
|
|
29
|
+
*,
|
|
30
|
+
artifact_id: str = "",
|
|
31
|
+
risk_score: float = 0.0,
|
|
32
|
+
reasoning: list[str] | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.artifact_id = artifact_id
|
|
36
|
+
self.risk_score = risk_score
|
|
37
|
+
self.reasoning = reasoning or []
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AGFReviewRequiredError(AGFError):
|
|
41
|
+
"""The action was flagged for human-in-the-loop review.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
approval_request_id: The HITL approval request ID.
|
|
45
|
+
artifact_id: Signed audit artifact ID.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
message: str,
|
|
51
|
+
*,
|
|
52
|
+
approval_request_id: str = "",
|
|
53
|
+
artifact_id: str = "",
|
|
54
|
+
) -> None:
|
|
55
|
+
super().__init__(message)
|
|
56
|
+
self.approval_request_id = approval_request_id
|
|
57
|
+
self.artifact_id = artifact_id
|
agf/govern.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""High-level synchronous facade — AgentGovernance.
|
|
2
|
+
|
|
3
|
+
The primary entry point for most users. Wraps :class:`~agf.sync.SyncAGFClient`
|
|
4
|
+
with a result-based API (no exceptions for deny/review) and convenience methods
|
|
5
|
+
for LangChain and CrewAI integration.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from agf import AgentGovernance
|
|
11
|
+
|
|
12
|
+
agf = AgentGovernance(
|
|
13
|
+
api_key=os.environ["AGF_API_KEY"],
|
|
14
|
+
org_id="org_acme",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
result = agf.authorize(
|
|
18
|
+
agent_id="did:agf:agt_01abc",
|
|
19
|
+
action="file:write",
|
|
20
|
+
resource="s3://corp-data/q2.csv",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if result.allowed:
|
|
24
|
+
# proceed
|
|
25
|
+
pass
|
|
26
|
+
else:
|
|
27
|
+
raise PermissionError(f"Denied: {result.reason}")
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from .client import AGFClient, Agent, DecisionResult
|
|
35
|
+
from .exceptions import AGFDeniedError, AGFReviewRequiredError
|
|
36
|
+
from .sync import SyncAGFClient
|
|
37
|
+
|
|
38
|
+
_DEFAULT_BASE_URL = "https://api.agentgovernancefoundation.com"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class AuthResult:
|
|
43
|
+
"""Returned by :meth:`AgentGovernance.authorize`.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
allowed: True when the PDP issued an ALLOW decision.
|
|
47
|
+
denied: True when the PDP issued a DENY decision.
|
|
48
|
+
review_required: True when the action needs human review (HITL).
|
|
49
|
+
reason: Human-readable denial or review reason.
|
|
50
|
+
artifact_id: Signed audit artifact ID.
|
|
51
|
+
risk_score: Risk score at time of decision (0.0–1.0).
|
|
52
|
+
trust_score: Trust score of the request chain (0–100).
|
|
53
|
+
approval_request_id: HITL approval request ID (review_required only).
|
|
54
|
+
policy_version: Policy version that evaluated the request.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
allowed: bool
|
|
58
|
+
denied: bool = False
|
|
59
|
+
review_required: bool = False
|
|
60
|
+
reason: str = ""
|
|
61
|
+
artifact_id: str = ""
|
|
62
|
+
risk_score: float = 0.0
|
|
63
|
+
trust_score: int = 0
|
|
64
|
+
approval_request_id: str = ""
|
|
65
|
+
policy_version: str = ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AgentGovernance:
|
|
69
|
+
"""Synchronous AGF client with a result-based authorization API.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
api_key: Org-level API key from Settings → API Keys.
|
|
73
|
+
org_id: Your organisation slug (stored for context; not sent to the API).
|
|
74
|
+
base_url: Override the default AGF runtime URL.
|
|
75
|
+
timeout: HTTP timeout in seconds (default 15).
|
|
76
|
+
|
|
77
|
+
Example::
|
|
78
|
+
|
|
79
|
+
agf = AgentGovernance(api_key="agfk_...", org_id="org_acme")
|
|
80
|
+
result = agf.authorize("did:agf:agt_01abc", "file:write", "s3://bucket/file.csv")
|
|
81
|
+
if result.allowed:
|
|
82
|
+
write_file()
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
api_key: str,
|
|
88
|
+
org_id: str = "",
|
|
89
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
90
|
+
timeout: float = 15.0,
|
|
91
|
+
) -> None:
|
|
92
|
+
self.org_id = org_id
|
|
93
|
+
self._sync = SyncAGFClient(api_key=api_key, base_url=base_url, timeout=timeout)
|
|
94
|
+
|
|
95
|
+
def authorize(
|
|
96
|
+
self,
|
|
97
|
+
agent_id: str,
|
|
98
|
+
action: str,
|
|
99
|
+
resource: str,
|
|
100
|
+
*,
|
|
101
|
+
chain: list[str] | None = None,
|
|
102
|
+
audience: str = "agf",
|
|
103
|
+
context: dict[str, Any] | None = None,
|
|
104
|
+
) -> AuthResult:
|
|
105
|
+
"""Check whether *agent_id* may perform *action* on *resource*.
|
|
106
|
+
|
|
107
|
+
Never raises :exc:`~agf.AGFDeniedError` or
|
|
108
|
+
:exc:`~agf.AGFReviewRequiredError` — check ``result.allowed`` instead.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
agent_id: DID of the acting agent (``did:agf:agt_...``).
|
|
112
|
+
action: Action string, e.g. ``"file:write"``.
|
|
113
|
+
resource: Target resource URI, e.g. ``"s3://bucket/file.csv"``.
|
|
114
|
+
chain: Optional JWT delegation chain.
|
|
115
|
+
audience: AGF audience field (default ``"agf"``).
|
|
116
|
+
context: Optional context dict (time_of_day, ip, session_id, …).
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
:class:`AuthResult` — inspect ``.allowed`` before proceeding.
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
result: DecisionResult = self._sync.decide(
|
|
123
|
+
action_type=action,
|
|
124
|
+
resource=resource,
|
|
125
|
+
chain=chain,
|
|
126
|
+
audience=audience,
|
|
127
|
+
context=context,
|
|
128
|
+
)
|
|
129
|
+
return AuthResult(
|
|
130
|
+
allowed=True,
|
|
131
|
+
artifact_id=result.artifact_id,
|
|
132
|
+
risk_score=result.risk_score,
|
|
133
|
+
trust_score=result.trust_score,
|
|
134
|
+
policy_version=result.policy_version,
|
|
135
|
+
)
|
|
136
|
+
except AGFDeniedError as exc:
|
|
137
|
+
return AuthResult(
|
|
138
|
+
allowed=False,
|
|
139
|
+
denied=True,
|
|
140
|
+
reason=str(exc),
|
|
141
|
+
artifact_id=exc.artifact_id,
|
|
142
|
+
risk_score=exc.risk_score,
|
|
143
|
+
)
|
|
144
|
+
except AGFReviewRequiredError as exc:
|
|
145
|
+
return AuthResult(
|
|
146
|
+
allowed=False,
|
|
147
|
+
review_required=True,
|
|
148
|
+
reason=str(exc),
|
|
149
|
+
artifact_id=exc.artifact_id,
|
|
150
|
+
approval_request_id=exc.approval_request_id,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def list_agents(self, *, status: str | None = None, page: int = 1, per_page: int = 50) -> list[Agent]:
|
|
154
|
+
"""List agents in the org. Thin pass-through to the REST API."""
|
|
155
|
+
return self._sync.list_agents(status=status, page=page, per_page=per_page)
|
|
156
|
+
|
|
157
|
+
def register_agent(
|
|
158
|
+
self,
|
|
159
|
+
name: str,
|
|
160
|
+
did: str,
|
|
161
|
+
public_key_pem: str,
|
|
162
|
+
metadata: dict[str, Any] | None = None,
|
|
163
|
+
) -> Agent:
|
|
164
|
+
"""Register a new agent identity. Thin pass-through to the REST API."""
|
|
165
|
+
return self._sync.register_agent(name, did, public_key_pem, metadata)
|
|
166
|
+
|
|
167
|
+
def langchain_tool(
|
|
168
|
+
self,
|
|
169
|
+
agent_id: str,
|
|
170
|
+
*,
|
|
171
|
+
audience: str = "agf",
|
|
172
|
+
chain: list[str] | None = None,
|
|
173
|
+
) -> Any:
|
|
174
|
+
"""Return a LangChain ``BaseTool`` that calls AGF before each tool use.
|
|
175
|
+
|
|
176
|
+
The returned tool is an *authorization gate* — add it to your agent's
|
|
177
|
+
tool list and the agent will call it to check access before acting.
|
|
178
|
+
For per-tool wrapping use :class:`~agf.langchain.AGFGuardedTool` directly.
|
|
179
|
+
|
|
180
|
+
Requires ``pip install agf-sdk[langchain]``.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
agent_id: DID of the acting agent.
|
|
184
|
+
audience: AGF audience field (default ``"agf"``).
|
|
185
|
+
chain: Optional JWT delegation chain.
|
|
186
|
+
"""
|
|
187
|
+
try:
|
|
188
|
+
from langchain_core.tools import BaseTool # type: ignore[import]
|
|
189
|
+
except ImportError as exc:
|
|
190
|
+
raise ImportError(
|
|
191
|
+
"langchain-core is required. Install with: pip install agf-sdk[langchain]"
|
|
192
|
+
) from exc
|
|
193
|
+
|
|
194
|
+
govern = self
|
|
195
|
+
_agent_id = agent_id
|
|
196
|
+
_audience = audience
|
|
197
|
+
_chain = chain
|
|
198
|
+
|
|
199
|
+
class _AGFAuthTool(BaseTool):
|
|
200
|
+
name: str = "agf_authorize"
|
|
201
|
+
description: str = (
|
|
202
|
+
"Check whether an action is authorized before executing it. "
|
|
203
|
+
"Input must be JSON with 'action' and 'resource' keys. "
|
|
204
|
+
"Always call this before performing sensitive operations."
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
class Config:
|
|
208
|
+
arbitrary_types_allowed = True
|
|
209
|
+
|
|
210
|
+
def _run(self, tool_input: str, **kwargs: Any) -> str:
|
|
211
|
+
import json
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
body = json.loads(tool_input)
|
|
215
|
+
except Exception:
|
|
216
|
+
body = {"action": str(tool_input), "resource": ""}
|
|
217
|
+
|
|
218
|
+
result = govern.authorize(
|
|
219
|
+
_agent_id,
|
|
220
|
+
body.get("action", ""),
|
|
221
|
+
body.get("resource", ""),
|
|
222
|
+
audience=_audience,
|
|
223
|
+
chain=_chain,
|
|
224
|
+
)
|
|
225
|
+
if result.allowed:
|
|
226
|
+
return f"AUTHORIZED — artifact_id={result.artifact_id}"
|
|
227
|
+
if result.review_required:
|
|
228
|
+
return f"REVIEW_REQUIRED — approval_request_id={result.approval_request_id}"
|
|
229
|
+
return f"DENIED — {result.reason}"
|
|
230
|
+
|
|
231
|
+
async def _arun(self, tool_input: str, **kwargs: Any) -> str:
|
|
232
|
+
return self._run(tool_input, **kwargs)
|
|
233
|
+
|
|
234
|
+
return _AGFAuthTool()
|
|
235
|
+
|
|
236
|
+
def async_client(self) -> AGFClient:
|
|
237
|
+
"""Return the underlying async :class:`~agf.client.AGFClient` for advanced use."""
|
|
238
|
+
return self._sync._async
|
|
239
|
+
|
|
240
|
+
def close(self) -> None:
|
|
241
|
+
self._sync.close()
|
|
242
|
+
|
|
243
|
+
def __enter__(self) -> "AgentGovernance":
|
|
244
|
+
return self
|
|
245
|
+
|
|
246
|
+
def __exit__(self, *_: Any) -> None:
|
|
247
|
+
self.close()
|
agf/langchain.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""LangChain integration — AGFGuardedTool.
|
|
2
|
+
|
|
3
|
+
Install extra: pip install agf-sdk[langchain]
|
|
4
|
+
|
|
5
|
+
Usage::
|
|
6
|
+
|
|
7
|
+
from langchain_community.tools import ShellTool
|
|
8
|
+
from agf.langchain import AGFGuardedTool
|
|
9
|
+
from agf import AGFClient
|
|
10
|
+
|
|
11
|
+
client = AGFClient(api_key="ak_live_...")
|
|
12
|
+
|
|
13
|
+
guarded = AGFGuardedTool(
|
|
14
|
+
tool=ShellTool(),
|
|
15
|
+
client=client,
|
|
16
|
+
agent_id="did:agf:my-assistant",
|
|
17
|
+
action_type="exec:shell",
|
|
18
|
+
resource="local-shell",
|
|
19
|
+
chain=[root_jwt, agent_jwt], # optional delegation chain
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# In a LangChain agent, use `guarded` in place of the original tool.
|
|
23
|
+
result = await guarded.arun("ls -la")
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import TYPE_CHECKING, Any
|
|
28
|
+
|
|
29
|
+
from .exceptions import AGFDeniedError, AGFReviewRequiredError
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from .client import AGFClient
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
from langchain_core.tools import BaseTool
|
|
36
|
+
except ImportError as exc: # pragma: no cover
|
|
37
|
+
raise ImportError(
|
|
38
|
+
"langchain-core is required for AGFGuardedTool. "
|
|
39
|
+
"Install with: pip install agf-sdk[langchain]"
|
|
40
|
+
) from exc
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AGFGuardedTool(BaseTool):
|
|
44
|
+
"""A LangChain tool wrapper that enforces AGF policy before execution.
|
|
45
|
+
|
|
46
|
+
Both the synchronous ``_run`` and asynchronous ``_arun`` paths are
|
|
47
|
+
guarded — async LangChain agents invoke ``_arun`` directly and would
|
|
48
|
+
bypass a guard placed only on ``_run``.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
tool: The underlying LangChain ``BaseTool`` to guard.
|
|
52
|
+
client: An initialised :class:`~agf.client.AGFClient`.
|
|
53
|
+
agent_id: DID or identifier for the acting agent.
|
|
54
|
+
action_type: AGF action type, e.g. ``"tool:shell"``.
|
|
55
|
+
Defaults to ``"tool:<tool_name>"``.
|
|
56
|
+
resource: AGF resource string. Defaults to the tool name.
|
|
57
|
+
chain: JWT delegation chain passed to ``/v1/decide``.
|
|
58
|
+
audience: AGF audience field (default ``"agf"``).
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
AGFDeniedError: Decision was DENY.
|
|
62
|
+
AGFReviewRequiredError: Decision was REVIEW_REQUIRED.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
# Pydantic fields — BaseTool is a pydantic BaseModel
|
|
66
|
+
tool: Any
|
|
67
|
+
client: Any
|
|
68
|
+
agent_id: str = ""
|
|
69
|
+
action_type: str = ""
|
|
70
|
+
resource: str = ""
|
|
71
|
+
chain: list[str] = []
|
|
72
|
+
audience: str = "agf"
|
|
73
|
+
|
|
74
|
+
class Config:
|
|
75
|
+
arbitrary_types_allowed = True
|
|
76
|
+
|
|
77
|
+
def model_post_init(self, __context: Any) -> None:
|
|
78
|
+
# Mirror name/description from the wrapped tool
|
|
79
|
+
if not self.name:
|
|
80
|
+
object.__setattr__(self, "name", f"agf_guarded_{self.tool.name}")
|
|
81
|
+
if not self.description:
|
|
82
|
+
object.__setattr__(self, "description", self.tool.description)
|
|
83
|
+
if not self.action_type:
|
|
84
|
+
object.__setattr__(self, "action_type", f"tool:{self.tool.name}")
|
|
85
|
+
if not self.resource:
|
|
86
|
+
object.__setattr__(self, "resource", self.tool.name)
|
|
87
|
+
|
|
88
|
+
async def _check_policy(self) -> None:
|
|
89
|
+
await self.client.decide(
|
|
90
|
+
self.action_type,
|
|
91
|
+
self.resource,
|
|
92
|
+
chain=self.chain or None,
|
|
93
|
+
audience=self.audience,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def _run(self, tool_input: str, **kwargs: Any) -> Any:
|
|
97
|
+
"""Sync path — runs policy check via a new event loop or nest_asyncio."""
|
|
98
|
+
import asyncio
|
|
99
|
+
|
|
100
|
+
loop: asyncio.AbstractEventLoop | None = None
|
|
101
|
+
try:
|
|
102
|
+
loop = asyncio.get_running_loop()
|
|
103
|
+
except RuntimeError:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
if loop and loop.is_running():
|
|
107
|
+
# Inside a running loop (e.g. Jupyter) — use nest_asyncio
|
|
108
|
+
try:
|
|
109
|
+
import nest_asyncio # type: ignore[import]
|
|
110
|
+
nest_asyncio.apply()
|
|
111
|
+
except ImportError:
|
|
112
|
+
pass
|
|
113
|
+
loop.run_until_complete(loop.create_task(self._check_policy()))
|
|
114
|
+
else:
|
|
115
|
+
asyncio.run(self._check_policy())
|
|
116
|
+
|
|
117
|
+
return self.tool._run(tool_input, **kwargs)
|
|
118
|
+
|
|
119
|
+
async def _arun(self, tool_input: str, **kwargs: Any) -> Any:
|
|
120
|
+
"""Async path — called by async LangChain agents."""
|
|
121
|
+
await self._check_policy()
|
|
122
|
+
if hasattr(self.tool, "_arun"):
|
|
123
|
+
return await self.tool._arun(tool_input, **kwargs)
|
|
124
|
+
return self.tool._run(tool_input, **kwargs)
|
agf/py.typed
ADDED
|
File without changes
|
agf/sync.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Synchronous wrappers for AGFClient methods.
|
|
2
|
+
|
|
3
|
+
For use in non-async contexts (scripts, Jupyter notebooks, Django views, etc.).
|
|
4
|
+
In an already-running event loop, ``nest_asyncio`` is applied automatically
|
|
5
|
+
if available; otherwise a ``RuntimeError`` is raised with instructions.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
from agf.sync import SyncAGFClient
|
|
10
|
+
|
|
11
|
+
client = SyncAGFClient(api_key="ak_live_...")
|
|
12
|
+
result = client.decide("write:database", "prod-customers")
|
|
13
|
+
agents = client.list_agents()
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .client import AGFClient, Agent, DecisionResult
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _run_async(coro: Any) -> Any:
|
|
24
|
+
"""Run a coroutine, handling both fresh and running event loops."""
|
|
25
|
+
try:
|
|
26
|
+
loop = asyncio.get_running_loop()
|
|
27
|
+
except RuntimeError:
|
|
28
|
+
loop = None
|
|
29
|
+
|
|
30
|
+
if loop and loop.is_running():
|
|
31
|
+
try:
|
|
32
|
+
import nest_asyncio # type: ignore[import]
|
|
33
|
+
nest_asyncio.apply()
|
|
34
|
+
return loop.run_until_complete(coro)
|
|
35
|
+
except ImportError:
|
|
36
|
+
raise RuntimeError(
|
|
37
|
+
"Cannot run synchronous AGF calls inside a running event loop. "
|
|
38
|
+
"Either use the async AGFClient directly, or install nest_asyncio: "
|
|
39
|
+
"pip install nest_asyncio"
|
|
40
|
+
) from None
|
|
41
|
+
return asyncio.run(coro)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SyncAGFClient:
|
|
45
|
+
"""Synchronous AGF client — thin wrapper around :class:`~agf.client.AGFClient`.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
api_key: Org-level API key from Settings → API Keys.
|
|
49
|
+
base_url: Override the default AGF runtime URL.
|
|
50
|
+
timeout: HTTP timeout in seconds.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
api_key: str,
|
|
56
|
+
base_url: str = "https://api.agentgovernancefoundation.com",
|
|
57
|
+
timeout: float = 15.0,
|
|
58
|
+
) -> None:
|
|
59
|
+
self._async = AGFClient(api_key=api_key, base_url=base_url, timeout=timeout)
|
|
60
|
+
|
|
61
|
+
def decide(
|
|
62
|
+
self,
|
|
63
|
+
action_type: str,
|
|
64
|
+
resource: str,
|
|
65
|
+
*,
|
|
66
|
+
chain: list[str] | None = None,
|
|
67
|
+
audience: str = "agf",
|
|
68
|
+
context: dict[str, Any] | None = None,
|
|
69
|
+
policy_version: str | None = None,
|
|
70
|
+
) -> DecisionResult:
|
|
71
|
+
return _run_async(
|
|
72
|
+
self._async.decide(
|
|
73
|
+
action_type,
|
|
74
|
+
resource,
|
|
75
|
+
chain=chain,
|
|
76
|
+
audience=audience,
|
|
77
|
+
context=context,
|
|
78
|
+
policy_version=policy_version,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def list_agents(
|
|
83
|
+
self,
|
|
84
|
+
*,
|
|
85
|
+
status: str | None = None,
|
|
86
|
+
page: int = 1,
|
|
87
|
+
per_page: int = 50,
|
|
88
|
+
) -> list[Agent]:
|
|
89
|
+
return _run_async(
|
|
90
|
+
self._async.list_agents(status=status, page=page, per_page=per_page)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def register_agent(
|
|
94
|
+
self,
|
|
95
|
+
name: str,
|
|
96
|
+
did: str,
|
|
97
|
+
public_key_pem: str,
|
|
98
|
+
metadata: dict[str, Any] | None = None,
|
|
99
|
+
) -> Agent:
|
|
100
|
+
return _run_async(
|
|
101
|
+
self._async.register_agent(name, did, public_key_pem, metadata)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def close(self) -> None:
|
|
105
|
+
_run_async(self._async.aclose())
|
|
106
|
+
|
|
107
|
+
def __enter__(self) -> "SyncAGFClient":
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
def __exit__(self, *_: Any) -> None:
|
|
111
|
+
self.close()
|
agf/webhook.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""AGF webhook payload verification and parsing."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AGFWebhookVerificationError(Exception):
|
|
12
|
+
"""Raised when the webhook signature does not match."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class WebhookEvent:
|
|
17
|
+
"""A parsed and verified AGF webhook payload.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
id: Unique event ID (``evt_...``).
|
|
21
|
+
type: Event type, e.g. ``decision.deny``.
|
|
22
|
+
org_id: Organisation that generated the event.
|
|
23
|
+
timestamp: Unix timestamp of the event.
|
|
24
|
+
data: Event-specific payload dict.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
id: str
|
|
28
|
+
type: str
|
|
29
|
+
org_id: str
|
|
30
|
+
timestamp: int
|
|
31
|
+
data: dict[str, Any]
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def decision(self) -> str | None:
|
|
35
|
+
return self.data.get("decision")
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def artifact_id(self) -> str | None:
|
|
39
|
+
return self.data.get("artifact_id")
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def agent_id(self) -> str | None:
|
|
43
|
+
return self.data.get("agent_id")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def verify_signature(payload: bytes, signature_header: str, secret: str) -> None:
|
|
47
|
+
"""Verify the ``X-AGF-Signature`` header on an incoming webhook request.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
payload: Raw request body bytes.
|
|
51
|
+
signature_header: Value of the ``X-AGF-Signature`` header.
|
|
52
|
+
secret: The plaintext webhook secret shown at creation.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
AGFWebhookVerificationError: Signature is invalid or missing.
|
|
56
|
+
|
|
57
|
+
Example (FastAPI)::
|
|
58
|
+
|
|
59
|
+
@app.post("/agf-webhook")
|
|
60
|
+
async def handle(request: Request):
|
|
61
|
+
body = await request.body()
|
|
62
|
+
agf.verify_signature(body, request.headers["X-AGF-Signature"], SECRET)
|
|
63
|
+
event = agf.parse_event(body)
|
|
64
|
+
...
|
|
65
|
+
"""
|
|
66
|
+
if not signature_header.startswith("sha256="):
|
|
67
|
+
raise AGFWebhookVerificationError("Missing or malformed X-AGF-Signature header.")
|
|
68
|
+
|
|
69
|
+
expected_hex = signature_header.removeprefix("sha256=")
|
|
70
|
+
mac = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
|
|
71
|
+
if not hmac.compare_digest(mac, expected_hex):
|
|
72
|
+
raise AGFWebhookVerificationError("Webhook signature mismatch — payload may have been tampered with.")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_event(payload: bytes | str) -> WebhookEvent:
|
|
76
|
+
"""Parse raw webhook payload bytes into a :class:`WebhookEvent`.
|
|
77
|
+
|
|
78
|
+
Does **not** verify the signature — call :func:`verify_signature` first.
|
|
79
|
+
"""
|
|
80
|
+
raw = json.loads(payload)
|
|
81
|
+
return WebhookEvent(
|
|
82
|
+
id=raw.get("id", ""),
|
|
83
|
+
type=raw.get("type", ""),
|
|
84
|
+
org_id=raw.get("org_id", ""),
|
|
85
|
+
timestamp=raw.get("timestamp", 0),
|
|
86
|
+
data=raw.get("data", {}),
|
|
87
|
+
)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agf-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Agent Governance Foundation — Python SDK
|
|
5
|
+
Project-URL: Homepage, https://agentgovernancefoundation.com
|
|
6
|
+
Project-URL: Documentation, https://agentgovernancefoundation.com/docs
|
|
7
|
+
License: MIT
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Provides-Extra: all
|
|
11
|
+
Requires-Dist: crewai>=0.28; extra == 'all'
|
|
12
|
+
Requires-Dist: langchain-core>=0.2; extra == 'all'
|
|
13
|
+
Provides-Extra: crewai
|
|
14
|
+
Requires-Dist: crewai>=0.28; extra == 'crewai'
|
|
15
|
+
Provides-Extra: langchain
|
|
16
|
+
Requires-Dist: langchain-core>=0.2; extra == 'langchain'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# agf-sdk
|
|
20
|
+
|
|
21
|
+
Python SDK for the [Agent Governance Foundation](https://agentgovernancefoundation.com) authorization service. Enforce identity, trust, and policy controls on every action your AI agents take.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install agf-sdk
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
With LangChain support:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install agf-sdk[langchain]
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
With CrewAI support:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install agf-sdk[crewai]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import os
|
|
45
|
+
from agf import AgentGovernance
|
|
46
|
+
|
|
47
|
+
agf = AgentGovernance(
|
|
48
|
+
api_key=os.environ["AGF_API_KEY"],
|
|
49
|
+
org_id="org_acme",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
result = agf.authorize(
|
|
53
|
+
agent_id="did:agf:agt_01abc",
|
|
54
|
+
action="file:write",
|
|
55
|
+
resource="s3://corp-data/q2.csv",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if result.allowed:
|
|
59
|
+
write_file()
|
|
60
|
+
else:
|
|
61
|
+
raise PermissionError(f"Denied: {result.reason}")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Authorization results
|
|
65
|
+
|
|
66
|
+
`authorize()` never raises for deny/review — it always returns an `AuthResult`:
|
|
67
|
+
|
|
68
|
+
| Field | Type | Description |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `allowed` | `bool` | `True` when the PDP issued ALLOW |
|
|
71
|
+
| `denied` | `bool` | `True` when the PDP issued DENY |
|
|
72
|
+
| `review_required` | `bool` | `True` when HITL approval is needed |
|
|
73
|
+
| `reason` | `str` | Human-readable denial reason |
|
|
74
|
+
| `artifact_id` | `str` | Signed audit artifact ID |
|
|
75
|
+
| `risk_score` | `float` | 0.0–1.0 |
|
|
76
|
+
| `trust_score` | `int` | 0–100 |
|
|
77
|
+
| `approval_request_id` | `str` | HITL request ID (review_required only) |
|
|
78
|
+
|
|
79
|
+
## Async client
|
|
80
|
+
|
|
81
|
+
For async frameworks (FastAPI, async Django, etc.) use `AGFClient` directly:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from agf import AGFClient, AGFDeniedError
|
|
85
|
+
|
|
86
|
+
async def handle_request():
|
|
87
|
+
async with AGFClient(api_key="agfk_...") as client:
|
|
88
|
+
try:
|
|
89
|
+
result = await client.decide(
|
|
90
|
+
action_type="file:write",
|
|
91
|
+
resource="s3://corp-data/q2.csv",
|
|
92
|
+
chain=[root_jwt, agent_jwt],
|
|
93
|
+
)
|
|
94
|
+
except AGFDeniedError as exc:
|
|
95
|
+
print(f"Denied — artifact: {exc.artifact_id}")
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## LangChain integration
|
|
99
|
+
|
|
100
|
+
### Authorization gate tool (recommended for most agents)
|
|
101
|
+
|
|
102
|
+
Add an authorization tool to your agent's tool list. The agent calls it before performing sensitive operations:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from agf import AgentGovernance
|
|
106
|
+
from langchain.agents import initialize_agent, AgentType
|
|
107
|
+
from langchain_openai import ChatOpenAI
|
|
108
|
+
|
|
109
|
+
agf = AgentGovernance(api_key="agfk_...", org_id="org_acme")
|
|
110
|
+
agf_tool = agf.langchain_tool(agent_id="did:agf:agt_01abc")
|
|
111
|
+
|
|
112
|
+
agent = initialize_agent(
|
|
113
|
+
tools=[agf_tool, *your_other_tools],
|
|
114
|
+
llm=ChatOpenAI(),
|
|
115
|
+
agent=AgentType.OPENAI_FUNCTIONS,
|
|
116
|
+
)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Per-tool guard (enforces policy on every tool call)
|
|
120
|
+
|
|
121
|
+
Wrap individual tools so no call can bypass the policy check:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from langchain_community.tools import ShellTool
|
|
125
|
+
from agf.langchain import AGFGuardedTool
|
|
126
|
+
from agf import AGFClient
|
|
127
|
+
|
|
128
|
+
client = AGFClient(api_key="agfk_...")
|
|
129
|
+
|
|
130
|
+
guarded_shell = AGFGuardedTool(
|
|
131
|
+
tool=ShellTool(),
|
|
132
|
+
client=client,
|
|
133
|
+
agent_id="did:agf:my-assistant",
|
|
134
|
+
action_type="exec:shell",
|
|
135
|
+
resource="local-shell",
|
|
136
|
+
)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## CrewAI integration
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from crewai import Agent
|
|
143
|
+
from crewai.tools import BaseTool as CrewBaseTool
|
|
144
|
+
from agf.crewai import AGFCrewAITool
|
|
145
|
+
from agf import AGFClient
|
|
146
|
+
|
|
147
|
+
client = AGFClient(api_key="agfk_...")
|
|
148
|
+
|
|
149
|
+
class MyDBTool(CrewBaseTool):
|
|
150
|
+
name: str = "database_query"
|
|
151
|
+
description: str = "Query the production database"
|
|
152
|
+
|
|
153
|
+
def _run(self, query: str) -> str:
|
|
154
|
+
return db.execute(query)
|
|
155
|
+
|
|
156
|
+
guarded = AGFCrewAITool(
|
|
157
|
+
tool=MyDBTool(),
|
|
158
|
+
client=client,
|
|
159
|
+
agent_id="did:agf:crew-researcher",
|
|
160
|
+
action_type="query:database",
|
|
161
|
+
resource="prod-db",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
crew_agent = Agent(tools=[guarded], ...)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Webhook verification
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from agf import verify_signature, parse_event, AGFWebhookVerificationError
|
|
171
|
+
|
|
172
|
+
# FastAPI example
|
|
173
|
+
from fastapi import FastAPI, Request, HTTPException
|
|
174
|
+
|
|
175
|
+
app = FastAPI()
|
|
176
|
+
|
|
177
|
+
@app.post("/agf-webhook")
|
|
178
|
+
async def handle(request: Request):
|
|
179
|
+
body = await request.body()
|
|
180
|
+
try:
|
|
181
|
+
verify_signature(body, request.headers["X-AGF-Signature"], WEBHOOK_SECRET)
|
|
182
|
+
except AGFWebhookVerificationError:
|
|
183
|
+
raise HTTPException(status_code=400, detail="Invalid signature")
|
|
184
|
+
|
|
185
|
+
event = parse_event(body)
|
|
186
|
+
if event.type == "decision.deny":
|
|
187
|
+
print(f"Agent {event.agent_id} was denied — artifact {event.artifact_id}")
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Sync client
|
|
191
|
+
|
|
192
|
+
For scripts, Django views, or any non-async context:
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
from agf import SyncAGFClient
|
|
196
|
+
|
|
197
|
+
with SyncAGFClient(api_key="agfk_...") as client:
|
|
198
|
+
result = client.decide("file:write", "s3://bucket/file.csv")
|
|
199
|
+
agents = client.list_agents(status="active")
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Environment variable
|
|
203
|
+
|
|
204
|
+
Set `AGF_API_KEY` in your environment and pass it via `os.environ["AGF_API_KEY"]`. The SDK does not auto-read environment variables — this keeps the dependency graph minimal and the behaviour explicit.
|
|
205
|
+
|
|
206
|
+
## Requirements
|
|
207
|
+
|
|
208
|
+
- Python 3.10+
|
|
209
|
+
- `httpx >= 0.27`
|
|
210
|
+
- `langchain-core >= 0.2` (optional, `agf-sdk[langchain]`)
|
|
211
|
+
- `crewai >= 0.28` (optional, `agf-sdk[crewai]`)
|
|
212
|
+
|
|
213
|
+
## Links
|
|
214
|
+
|
|
215
|
+
- [Documentation](https://agentgovernancefoundation.com/docs)
|
|
216
|
+
- [Quick start](https://agentgovernancefoundation.com/docs/quick-start)
|
|
217
|
+
- [API reference](https://agentgovernancefoundation.com/docs/api)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
agf/__init__.py,sha256=eVQVl8SxtrPlHp1QYbPE0XC0DlV9XDj9afbe0YE_cmc,885
|
|
2
|
+
agf/client.py,sha256=WFucCrlmyGuXBJkwINitjxd-YtN9xkErolOI2tQan7M,6829
|
|
3
|
+
agf/crewai.py,sha256=oN8aWwTms3UG1gpsy5JhTNTi22QDyBgW9yWM1vkhyuM,4249
|
|
4
|
+
agf/exceptions.py,sha256=9p2EXFvZLLvndXmlgt3mxF4DGpYtnN15y2zmoEoADcc,1464
|
|
5
|
+
agf/govern.py,sha256=MMW3lV8LK9e_cOmQTfwI9DgLdVxF1ayMVOtdwhCiBa4,8420
|
|
6
|
+
agf/langchain.py,sha256=oJK_rjtUtB8TAeyjziXUfxH1TfazhlCMwqr-4OJur7s,4192
|
|
7
|
+
agf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
agf/sync.py,sha256=lluv6wCeCKImqor8wuoDuxdC_4T-WamurPZJxBHXU0Q,3177
|
|
9
|
+
agf/webhook.py,sha256=RoLOOBrcWKbQN9o8R-hwY_Krtf4o2_RuBdCCvRESmkU,2682
|
|
10
|
+
agf_sdk-0.1.0.dist-info/METADATA,sha256=qFTOLcgqLwmEwkgTtu7qs9qjg1fIMSz-x8YutZk2ud0,5719
|
|
11
|
+
agf_sdk-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
12
|
+
agf_sdk-0.1.0.dist-info/RECORD,,
|