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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any