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.
- odingard_guardian/__init__.py +63 -0
- odingard_guardian/_canonical.py +151 -0
- odingard_guardian/_evidence.py +352 -0
- odingard_guardian/client.py +406 -0
- odingard_guardian/guard.py +530 -0
- odingard_guardian/lifecycle.py +45 -0
- odingard_guardian/py.typed +0 -0
- odingard_guardian/receipts.py +505 -0
- odingard_guardian-0.1.0.dist-info/METADATA +199 -0
- odingard_guardian-0.1.0.dist-info/RECORD +12 -0
- odingard_guardian-0.1.0.dist-info/WHEEL +4 -0
- odingard_guardian-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -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]
|