odingard-guardian 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.
@@ -0,0 +1,406 @@
1
+ """Guardian agent client: request, claim, execute and receipts.
2
+
3
+ A port of ``createGuardianClient`` (``packages/sdk/src/client.ts``). The
4
+ client builds the same canonical action proposal the TypeScript SDK sends,
5
+ so the gateway, kernel and capability path govern Python agents exactly
6
+ like TypeScript ones. An unreachable Guardian is always a refusal
7
+ (``SERVICE_UNAVAILABLE``), never an allow.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import secrets
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Dict, Literal, Mapping, Optional, Union
15
+
16
+ import httpx
17
+
18
+ __all__ = [
19
+ "DEFAULT_BASE_URL",
20
+ "RequestResult",
21
+ "ExecuteResult",
22
+ "ReceiptsResult",
23
+ "PortableReceiptResult",
24
+ "GuardianClient",
25
+ "AsyncGuardianClient",
26
+ "build_proposal",
27
+ ]
28
+
29
+ DEFAULT_BASE_URL = "https://guardian-api.odingard.com"
30
+
31
+ Decision = Literal["ALLOW", "ASK", "DENY"]
32
+
33
+ _RESOURCE_TYPE = {
34
+ "tool.invoke": "tool:server",
35
+ "mcp.install": "mcp:server",
36
+ "mcp.enable": "mcp:server",
37
+ "api.request": "api:endpoint",
38
+ "protected_data.derive": "protected:data",
39
+ "protected_data.transform": "protected:data",
40
+ "protected_data.export": "protected:data",
41
+ "agent.message": "guardian-agent",
42
+ "agent.share_context": "guardian-agent",
43
+ "agent.share_memory": "guardian-agent",
44
+ "agent.transfer_task": "guardian-agent",
45
+ "agent.transfer_capability": "guardian-agent",
46
+ }
47
+
48
+ _DEFAULT_ADAPTER = {
49
+ "api.request": "adp_00000002",
50
+ "tool.invoke": "adp_00000006",
51
+ "mcp.install": "adp_00000006",
52
+ "mcp.enable": "adp_00000006",
53
+ "protected_data.derive": "adp_00000005",
54
+ "protected_data.transform": "adp_00000005",
55
+ "protected_data.export": "adp_00000005",
56
+ "agent.message": "adp_00000007",
57
+ "agent.share_context": "adp_00000007",
58
+ "agent.share_memory": "adp_00000007",
59
+ "agent.transfer_task": "adp_00000007",
60
+ "agent.transfer_capability": "adp_00000007",
61
+ }
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class RequestResult:
66
+ """Outcome of ``request`` or ``claim``.
67
+
68
+ ``ok=True`` carries a ``decision``; ALLOW carries a ``capability_token``.
69
+ ``ok=False`` carries a ``reason_code``. A held ASK is reported by the
70
+ gateway as ``ok=False, reason_code="APPROVAL_REQUIRED"`` with its
71
+ ``request_id``; :func:`classify_request_result` reads both forms.
72
+ """
73
+
74
+ ok: bool
75
+ request_id: Optional[str] = None
76
+ decision: Optional[Decision] = None
77
+ capability_token: Optional[str] = None
78
+ reason_code: Optional[str] = None
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class ExecuteResult:
83
+ ok: bool
84
+ receipt: Optional[Dict[str, Any]] = None
85
+ reason_code: Optional[str] = None
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class ReceiptsResult:
90
+ ok: bool
91
+ request_id: Optional[str] = None
92
+ receipts: list = field(default_factory=list)
93
+ reason_code: Optional[str] = None
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class PortableReceiptResult:
98
+ ok: bool
99
+ receipt: Optional[Dict[str, Any]] = None
100
+ reason_code: Optional[str] = None
101
+
102
+
103
+ def _random_id(prefix: str) -> str:
104
+ return f"{prefix}_{secrets.token_hex(16)}"
105
+
106
+
107
+ def _material_parameters(invocation: Mapping[str, Any]) -> Dict[str, Any]:
108
+ action = invocation["action"]
109
+ if "material_parameters" in invocation:
110
+ return dict(invocation["material_parameters"])
111
+ connection_id = invocation["connection_id"]
112
+ if action == "tool.invoke":
113
+ arguments = invocation.get("arguments") or {}
114
+ if not isinstance(arguments, Mapping):
115
+ raise TypeError("tool.invoke arguments must be a mapping")
116
+ return {
117
+ "schema": "odingard.guardian.tool.invoke.v1",
118
+ "connectionId": connection_id,
119
+ "toolName": invocation["tool_name"],
120
+ "arguments": dict(arguments),
121
+ }
122
+ if action == "mcp.install":
123
+ return {
124
+ "schema": "odingard.guardian.mcp.install.v1",
125
+ "connectionId": connection_id,
126
+ "tool": dict(invocation["tool"]),
127
+ }
128
+ if action == "mcp.enable":
129
+ return {
130
+ "schema": "odingard.guardian.mcp.enable.v1",
131
+ "connectionId": connection_id,
132
+ "toolName": invocation["tool_name"],
133
+ }
134
+ raise ValueError(f"{action} needs material_parameters")
135
+
136
+
137
+ def build_proposal(invocation: Mapping[str, Any]) -> Dict[str, Any]:
138
+ """Build the canonical agent action proposal for an invocation.
139
+
140
+ ``invocation`` keys: ``action``, ``connection_id``, and per action
141
+ ``tool_name`` + ``arguments`` (tool.invoke), ``tool`` (mcp.install),
142
+ ``tool_name`` (mcp.enable) or ``material_parameters`` (governed actions);
143
+ optional ``adapter_id``, ``purpose``, ``intent_id``, ``plan_id``,
144
+ ``parent_action_id``, ``source_effect_id``.
145
+ """
146
+ action = invocation["action"]
147
+ if action not in _RESOURCE_TYPE:
148
+ raise ValueError(f"unsupported action: {action}")
149
+ connection_id = invocation.get("connection_id")
150
+ if not isinstance(connection_id, str) or not connection_id:
151
+ raise ValueError("connection_id is required")
152
+ proposal: Dict[str, Any] = {
153
+ "schemaVersion": 1,
154
+ "requestId": _random_id("req"),
155
+ "correlationId": _random_id("cor"),
156
+ "action": action,
157
+ "resource": f"{_RESOURCE_TYPE[action]}:{connection_id}",
158
+ "uses": 1,
159
+ "materialParameters": _material_parameters(invocation),
160
+ "purpose": invocation.get("purpose") or f"Guardian {action}",
161
+ "initiatingContext": "agent",
162
+ "requestedAdapterId": invocation.get("adapter_id")
163
+ or _DEFAULT_ADAPTER.get(action, "adp_00000006"),
164
+ "requestedTtlMs": 120_000,
165
+ }
166
+ for source, target in (
167
+ ("intent_id", "intentId"),
168
+ ("plan_id", "planId"),
169
+ ("parent_action_id", "parentActionId"),
170
+ ("source_effect_id", "sourceEffectId"),
171
+ ):
172
+ if invocation.get(source) is not None:
173
+ proposal[target] = invocation[source]
174
+ return proposal
175
+
176
+
177
+ def _request_result(status: int, data: Any, request_id: Optional[str]) -> RequestResult:
178
+ if not isinstance(data, dict) or data.get("ok") is not True:
179
+ reason = (data.get("reasonCode") or data.get("code")) if isinstance(data, dict) else None
180
+ return RequestResult(
181
+ ok=False,
182
+ reason_code=reason if isinstance(reason, str) else f"HTTP_{status}",
183
+ request_id=request_id,
184
+ )
185
+ decision = data.get("decision")
186
+ token = data.get("capabilityToken")
187
+ if not isinstance(token, str):
188
+ token = data.get("token") if isinstance(data.get("token"), str) else None
189
+ rid = data.get("requestId")
190
+ return RequestResult(
191
+ ok=True,
192
+ request_id=rid if isinstance(rid, str) else (request_id or ""),
193
+ decision=decision if decision in ("ASK", "DENY") else "ALLOW",
194
+ capability_token=token,
195
+ reason_code=data.get("reasonCode") if isinstance(data.get("reasonCode"), str) else None,
196
+ )
197
+
198
+
199
+ def _execute_result(status: int, data: Any) -> ExecuteResult:
200
+ receipt = data.get("receipt") if isinstance(data, dict) else None
201
+ if (
202
+ not isinstance(data, dict)
203
+ or data.get("ok") is not True
204
+ or not isinstance(receipt, dict)
205
+ or receipt.get("schemaVersion") != 1
206
+ or not isinstance(receipt.get("requestId"), str)
207
+ ):
208
+ reason = (data.get("reasonCode") or data.get("code")) if isinstance(data, dict) else None
209
+ return ExecuteResult(ok=False, reason_code=reason if isinstance(reason, str) else f"HTTP_{status}")
210
+ return ExecuteResult(ok=True, receipt=receipt)
211
+
212
+
213
+ def _receipts_result(status: int, data: Any) -> ReceiptsResult:
214
+ if not isinstance(data, dict) or data.get("ok") is not True or not isinstance(data.get("receipts"), list):
215
+ code = data.get("code") if isinstance(data, dict) else None
216
+ return ReceiptsResult(ok=False, reason_code=code if isinstance(code, str) else f"HTTP_{status}")
217
+ return ReceiptsResult(ok=True, request_id=data.get("requestId"), receipts=data["receipts"])
218
+
219
+
220
+ def _portable_result(status: int, data: Any) -> PortableReceiptResult:
221
+ if not isinstance(data, dict) or data.get("ok") is not True or not isinstance(data.get("receipt"), dict):
222
+ reason = None
223
+ if isinstance(data, dict):
224
+ reason = data.get("reasonCode") or data.get("code")
225
+ return PortableReceiptResult(
226
+ ok=False, reason_code=reason if isinstance(reason, str) else f"HTTP_{status}"
227
+ )
228
+ return PortableReceiptResult(ok=True, receipt=data["receipt"])
229
+
230
+
231
+ _UNAVAILABLE = (0, {"ok": False, "reasonCode": "SERVICE_UNAVAILABLE"})
232
+
233
+
234
+ class _Base:
235
+ def __init__(
236
+ self,
237
+ *,
238
+ credential: str,
239
+ base_url: str = DEFAULT_BASE_URL,
240
+ adapter_id: Optional[str] = None,
241
+ timeout: float = 30.0,
242
+ ) -> None:
243
+ if not credential:
244
+ raise ValueError("credential (the agent credential, gac1.…) is required")
245
+ self.base_url = base_url.rstrip("/")
246
+ self.adapter_id = adapter_id
247
+ self._headers = {"authorization": f"Bearer {credential}"}
248
+ self._timeout = timeout
249
+
250
+ def _proposal(self, invocation: Mapping[str, Any]) -> Dict[str, Any]:
251
+ merged = dict(invocation)
252
+ if self.adapter_id and not merged.get("adapter_id") and merged["action"] in (
253
+ "tool.invoke",
254
+ "mcp.install",
255
+ "mcp.enable",
256
+ ):
257
+ merged["adapter_id"] = self.adapter_id
258
+ return build_proposal(merged)
259
+
260
+ @staticmethod
261
+ def _parse(response: httpx.Response) -> tuple:
262
+ try:
263
+ return response.status_code, response.json()
264
+ except ValueError:
265
+ return response.status_code, None
266
+
267
+ def __repr__(self) -> str: # never print the credential
268
+ return f"{type(self).__name__}(base_url={self.base_url!r})"
269
+
270
+
271
+ class GuardianClient(_Base):
272
+ """Synchronous Guardian agent client.
273
+
274
+ >>> client = GuardianClient(credential=os.environ["GUARDIAN_AGENT_CREDENTIAL"])
275
+ >>> result = client.request({"action": "tool.invoke", "connection_id": "con_…",
276
+ ... "tool_name": "refund_order", "arguments": {"orderId": "ord_1"}})
277
+ """
278
+
279
+ def __init__(
280
+ self,
281
+ *,
282
+ credential: str,
283
+ base_url: str = DEFAULT_BASE_URL,
284
+ adapter_id: Optional[str] = None,
285
+ timeout: float = 30.0,
286
+ transport: Optional[httpx.BaseTransport] = None,
287
+ ) -> None:
288
+ super().__init__(credential=credential, base_url=base_url, adapter_id=adapter_id, timeout=timeout)
289
+ self._http = httpx.Client(transport=transport, timeout=timeout, headers=self._headers)
290
+
291
+ def close(self) -> None:
292
+ self._http.close()
293
+
294
+ def __enter__(self) -> "GuardianClient":
295
+ return self
296
+
297
+ def __exit__(self, *exc: object) -> None:
298
+ self.close()
299
+
300
+ def _call(self, path: str, body: Any = None, method: str = "POST") -> tuple:
301
+ try:
302
+ if method == "GET":
303
+ response = self._http.get(f"{self.base_url}{path}")
304
+ else:
305
+ response = self._http.post(f"{self.base_url}{path}", json=body)
306
+ except httpx.HTTPError:
307
+ return _UNAVAILABLE # fail closed
308
+ return self._parse(response)
309
+
310
+ def request(self, invocation: Mapping[str, Any]) -> RequestResult:
311
+ """Request a capability. ALLOW → token; ASK → held for a human; DENY → refused."""
312
+ proposal = self._proposal(invocation)
313
+ status, data = self._call("/v1/agent/requests", proposal)
314
+ return _request_result(status, data, proposal["requestId"])
315
+
316
+ def claim(self, request_id: str) -> RequestResult:
317
+ """Collect the capability after a human approved a prior ASK."""
318
+ status, data = self._call("/v1/agent/claims", {"schemaVersion": 1, "requestId": request_id})
319
+ return _request_result(status, data, request_id)
320
+
321
+ def execute(self, *, request_id: str, capability_token: str) -> ExecuteResult:
322
+ """Redeem a capability; resolves to the execution receipt."""
323
+ status, data = self._call(
324
+ "/v1/agent/execute",
325
+ {"schemaVersion": 1, "requestId": request_id, "capabilityToken": capability_token},
326
+ )
327
+ return _execute_result(status, data)
328
+
329
+ def receipts(self, request_id: str) -> ReceiptsResult:
330
+ status, data = self._call(f"/v1/agent/receipts/{_quote(request_id)}", method="GET")
331
+ return _receipts_result(status, data)
332
+
333
+ def portable_receipt(self, request_id: str) -> PortableReceiptResult:
334
+ """The signed ``greceipt.v1`` for a completed request (verify it offline)."""
335
+ status, data = self._call(f"/v1/agent/receipts/{_quote(request_id)}/portable", method="GET")
336
+ return _portable_result(status, data)
337
+
338
+
339
+ class AsyncGuardianClient(_Base):
340
+ """``asyncio`` Guardian agent client — same surface as :class:`GuardianClient`."""
341
+
342
+ def __init__(
343
+ self,
344
+ *,
345
+ credential: str,
346
+ base_url: str = DEFAULT_BASE_URL,
347
+ adapter_id: Optional[str] = None,
348
+ timeout: float = 30.0,
349
+ transport: Optional[httpx.AsyncBaseTransport] = None,
350
+ ) -> None:
351
+ super().__init__(credential=credential, base_url=base_url, adapter_id=adapter_id, timeout=timeout)
352
+ self._http = httpx.AsyncClient(transport=transport, timeout=timeout, headers=self._headers)
353
+
354
+ async def aclose(self) -> None:
355
+ await self._http.aclose()
356
+
357
+ async def __aenter__(self) -> "AsyncGuardianClient":
358
+ return self
359
+
360
+ async def __aexit__(self, *exc: object) -> None:
361
+ await self.aclose()
362
+
363
+ async def _call(self, path: str, body: Any = None, method: str = "POST") -> tuple:
364
+ try:
365
+ if method == "GET":
366
+ response = await self._http.get(f"{self.base_url}{path}")
367
+ else:
368
+ response = await self._http.post(f"{self.base_url}{path}", json=body)
369
+ except httpx.HTTPError:
370
+ return _UNAVAILABLE # fail closed
371
+ return self._parse(response)
372
+
373
+ async def request(self, invocation: Mapping[str, Any]) -> RequestResult:
374
+ proposal = self._proposal(invocation)
375
+ status, data = await self._call("/v1/agent/requests", proposal)
376
+ return _request_result(status, data, proposal["requestId"])
377
+
378
+ async def claim(self, request_id: str) -> RequestResult:
379
+ status, data = await self._call("/v1/agent/claims", {"schemaVersion": 1, "requestId": request_id})
380
+ return _request_result(status, data, request_id)
381
+
382
+ async def execute(self, *, request_id: str, capability_token: str) -> ExecuteResult:
383
+ status, data = await self._call(
384
+ "/v1/agent/execute",
385
+ {"schemaVersion": 1, "requestId": request_id, "capabilityToken": capability_token},
386
+ )
387
+ return _execute_result(status, data)
388
+
389
+ async def receipts(self, request_id: str) -> ReceiptsResult:
390
+ status, data = await self._call(f"/v1/agent/receipts/{_quote(request_id)}", method="GET")
391
+ return _receipts_result(status, data)
392
+
393
+ async def portable_receipt(self, request_id: str) -> PortableReceiptResult:
394
+ status, data = await self._call(
395
+ f"/v1/agent/receipts/{_quote(request_id)}/portable", method="GET"
396
+ )
397
+ return _portable_result(status, data)
398
+
399
+
400
+ def _quote(value: str) -> str:
401
+ from urllib.parse import quote
402
+
403
+ return quote(value, safe="")
404
+
405
+
406
+ AnyGuardianClient = Union[GuardianClient, AsyncGuardianClient]