agent-custody 0.1.2__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,95 @@
|
|
|
1
|
+
"""Receipts for tool calls, from Python.
|
|
2
|
+
|
|
3
|
+
The signing key, the policy, and the log live in the agent-custody sidecar, `agent-custody serve --config sdk.json`,
|
|
4
|
+
a local process. This client talks to it over HTTP with nothing but the standard library. Everything recorded is
|
|
5
|
+
claimed, exactly as with the in-process TypeScript SDK: the sidecar trusts what this process reports.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
from typing import Any, Callable, Dict, Optional
|
|
14
|
+
|
|
15
|
+
__all__ = ["Client", "PolicyDeniedError", "SidecarError", "receipt_id_of"]
|
|
16
|
+
|
|
17
|
+
DEFAULT_URL = "http://127.0.0.1:8788/"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SidecarError(RuntimeError):
|
|
21
|
+
"""The sidecar refused or could not complete a request. No receipt was written."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PolicyDeniedError(PermissionError):
|
|
25
|
+
def __init__(self, tool: str, reason: str, receipt_id: str):
|
|
26
|
+
super().__init__(f"Denied by policy: {reason} (receipt {receipt_id})")
|
|
27
|
+
self.tool = tool
|
|
28
|
+
self.reason = reason
|
|
29
|
+
self.receipt_id = receipt_id
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def receipt_id_of(bundle: Dict[str, Any]) -> str:
|
|
33
|
+
payload = json.loads(base64.b64decode(bundle["envelope"]["payload"]))
|
|
34
|
+
return payload["predicate"]["receiptId"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _event(tool: str, args: Optional[Dict[str, Any]], model: Optional[str], session: Optional[Dict[str, Optional[str]]]) -> Dict[str, Any]:
|
|
38
|
+
ev: Dict[str, Any] = {"tool": tool, "args": args or {}}
|
|
39
|
+
if model is not None:
|
|
40
|
+
ev["model"] = model
|
|
41
|
+
if session is not None:
|
|
42
|
+
ev["session"] = session
|
|
43
|
+
return ev
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Client:
|
|
47
|
+
def __init__(self, url: str = DEFAULT_URL, timeout: float = 10.0):
|
|
48
|
+
self.url = url if url.endswith("/") else url + "/"
|
|
49
|
+
self.timeout = timeout
|
|
50
|
+
|
|
51
|
+
def _post(self, path: str, body: Any) -> Any:
|
|
52
|
+
req = urllib.request.Request(self.url + path, data=json.dumps(body).encode(), headers={"content-type": "application/json"}, method="POST")
|
|
53
|
+
try:
|
|
54
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as res:
|
|
55
|
+
return json.loads(res.read())
|
|
56
|
+
except urllib.error.HTTPError as e:
|
|
57
|
+
try:
|
|
58
|
+
detail = json.loads(e.read()).get("error", "")
|
|
59
|
+
except Exception:
|
|
60
|
+
detail = ""
|
|
61
|
+
raise SidecarError(f"sidecar {path}: {e.code} {detail}".strip()) from None
|
|
62
|
+
except urllib.error.URLError as e:
|
|
63
|
+
raise SidecarError(f"sidecar unreachable at {self.url}: {e.reason}") from None
|
|
64
|
+
|
|
65
|
+
def health(self) -> Dict[str, Any]:
|
|
66
|
+
with urllib.request.urlopen(self.url + "health", timeout=self.timeout) as res:
|
|
67
|
+
return json.loads(res.read())
|
|
68
|
+
|
|
69
|
+
def decide(self, tool: str, args: Optional[Dict[str, Any]] = None, *, model: Optional[str] = None, session: Optional[Dict[str, Optional[str]]] = None) -> Optional[Dict[str, Any]]:
|
|
70
|
+
"""The configured policy's decision for this call, or None when the sidecar has no policy."""
|
|
71
|
+
return self._post("decide", _event(tool, args, model, session))
|
|
72
|
+
|
|
73
|
+
def record(self, tool: str, args: Optional[Dict[str, Any]], outcome: Dict[str, Any], policy: Optional[Dict[str, Any]] = None, *, model: Optional[str] = None, session: Optional[Dict[str, Optional[str]]] = None) -> Dict[str, Any]:
|
|
74
|
+
"""Issues one receipt. `outcome` is {"status": "executed"|"failed", "result": ...}, {"status": "denied", "reason": ...}, or {"status": "error", "error": ...}."""
|
|
75
|
+
return self._post("record", {"event": _event(tool, args, model, session), "outcome": outcome, "policy": policy})
|
|
76
|
+
|
|
77
|
+
def wrap(self, tool: str, fn: Callable[[Dict[str, Any]], Any], *, model: Optional[str] = None) -> Callable[[Dict[str, Any]], Any]:
|
|
78
|
+
"""decide, run, record. Raises PolicyDeniedError on deny, after recording the denial."""
|
|
79
|
+
|
|
80
|
+
def wrapped(args: Dict[str, Any]) -> Any:
|
|
81
|
+
policy = self.decide(tool, args, model=model)
|
|
82
|
+
if policy and policy["decision"] == "deny":
|
|
83
|
+
reason = "; ".join(policy["reasons"] + policy["errors"]) or "no permit policy matched"
|
|
84
|
+
bundle = self.record(tool, args, {"status": "denied", "reason": reason}, policy, model=model)
|
|
85
|
+
raise PolicyDeniedError(tool, reason, receipt_id_of(bundle))
|
|
86
|
+
try:
|
|
87
|
+
result = fn(args)
|
|
88
|
+
except Exception as e:
|
|
89
|
+
self.record(tool, args, {"status": "error", "error": str(e)}, policy, model=model)
|
|
90
|
+
raise
|
|
91
|
+
self.record(tool, args, {"status": "executed", "result": result}, policy, model=model)
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
wrapped.__name__ = getattr(fn, "__name__", tool)
|
|
95
|
+
return wrapped
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Claude Agent SDK (Python) hooks. Same contract as the TypeScript adapter and the Claude Code command hook.
|
|
2
|
+
|
|
3
|
+
PreToolUse: evaluate policy; on deny, record a denial receipt and block. On allow or no policy return no decision,
|
|
4
|
+
so the host's own permission flow still applies. This never auto-approves. PostToolUse / PostToolUseFailure record.
|
|
5
|
+
|
|
6
|
+
from claude_agent_sdk import HookMatcher
|
|
7
|
+
hooks = {event: [HookMatcher(hooks=[claude_hook(client)])] for event in ("PreToolUse", "PostToolUse", "PostToolUseFailure")}
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Callable, Dict, Optional
|
|
12
|
+
|
|
13
|
+
from . import Client, receipt_id_of
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _event(input_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
17
|
+
raw = input_data.get("tool_input")
|
|
18
|
+
args = raw if isinstance(raw, dict) else {"input": raw}
|
|
19
|
+
return {"tool": input_data["tool_name"], "args": args, "session": {"id": input_data.get("session_id"), "toolUseId": input_data.get("tool_use_id")}}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def handle_hook_event(client: Client, input_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
23
|
+
ev = _event(input_data)
|
|
24
|
+
name = input_data.get("hook_event_name")
|
|
25
|
+
if name == "PreToolUse":
|
|
26
|
+
policy = client.decide(ev["tool"], ev["args"], session=ev["session"])
|
|
27
|
+
if policy and policy["decision"] == "deny":
|
|
28
|
+
reason = "; ".join(policy["reasons"] + policy["errors"]) or "no permit policy matched"
|
|
29
|
+
bundle = client.record(ev["tool"], ev["args"], {"status": "denied", "reason": reason}, policy, session=ev["session"])
|
|
30
|
+
return {"continue": True, "hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": f"agent-custody: {reason} (receipt {receipt_id_of(bundle)})"}}
|
|
31
|
+
return {}
|
|
32
|
+
if name == "PostToolUse":
|
|
33
|
+
client.record(ev["tool"], ev["args"], {"status": "executed", "result": input_data.get("tool_response")}, client.decide(ev["tool"], ev["args"]), session=ev["session"])
|
|
34
|
+
return {}
|
|
35
|
+
if name == "PostToolUseFailure":
|
|
36
|
+
client.record(ev["tool"], ev["args"], {"status": "failed", "result": input_data.get("error", input_data.get("tool_response"))}, client.decide(ev["tool"], ev["args"]), session=ev["session"])
|
|
37
|
+
return {}
|
|
38
|
+
return {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def claude_hook(client: Client) -> Callable[[Dict[str, Any], Optional[str], Any], Any]:
|
|
42
|
+
"""A hook callable in the shape the Claude Agent SDK expects: (input_data, tool_use_id, context) -> dict."""
|
|
43
|
+
|
|
44
|
+
async def hook(input_data: Dict[str, Any], tool_use_id: Optional[str], context: Any) -> Dict[str, Any]:
|
|
45
|
+
return handle_hook_event(client, input_data)
|
|
46
|
+
|
|
47
|
+
return hook
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""LangChain / LangGraph adapter: a callback handler that records a receipt for every tool run it sees.
|
|
2
|
+
|
|
3
|
+
Observe-only. Callbacks cannot block a tool, so this evaluates no policy. For enforcement wrap the function before
|
|
4
|
+
turning it into a tool: `tool(client.wrap("name", fn))`. Do not combine both on one tool, or it is recorded twice.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
from uuid import UUID
|
|
11
|
+
|
|
12
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
13
|
+
|
|
14
|
+
from . import Client
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_args(input_str: str, inputs: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
18
|
+
if isinstance(inputs, dict):
|
|
19
|
+
return inputs
|
|
20
|
+
try:
|
|
21
|
+
v = json.loads(input_str)
|
|
22
|
+
return v if isinstance(v, dict) else {"input": v}
|
|
23
|
+
except (TypeError, ValueError):
|
|
24
|
+
return {"input": input_str}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _unwrap(output: Any) -> Any:
|
|
28
|
+
content = getattr(output, "content", None) if hasattr(output, "tool_call_id") else None
|
|
29
|
+
if content is None:
|
|
30
|
+
return output
|
|
31
|
+
if isinstance(content, str):
|
|
32
|
+
try:
|
|
33
|
+
return json.loads(content)
|
|
34
|
+
except ValueError:
|
|
35
|
+
return content
|
|
36
|
+
return content
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ReceiptCallbackHandler(BaseCallbackHandler):
|
|
40
|
+
name = "agent-custody"
|
|
41
|
+
|
|
42
|
+
def __init__(self, client: Client):
|
|
43
|
+
super().__init__()
|
|
44
|
+
self.client = client
|
|
45
|
+
self._pending: Dict[UUID, Dict[str, Any]] = {}
|
|
46
|
+
|
|
47
|
+
def on_tool_start(self, serialized: Dict[str, Any], input_str: str, *, run_id: UUID, parent_run_id: Optional[UUID] = None, tags=None, metadata=None, inputs: Optional[Dict[str, Any]] = None, **kwargs: Any) -> None:
|
|
48
|
+
name = kwargs.get("name") or (serialized or {}).get("name") or "unknown"
|
|
49
|
+
self._pending[run_id] = {"tool": name, "args": _parse_args(input_str, inputs), "session": {"id": None, "toolUseId": kwargs.get("tool_call_id")}}
|
|
50
|
+
|
|
51
|
+
def on_tool_end(self, output: Any, *, run_id: UUID, **kwargs: Any) -> None:
|
|
52
|
+
ev = self._pending.pop(run_id, None)
|
|
53
|
+
if ev is None:
|
|
54
|
+
return
|
|
55
|
+
self.client.record(ev["tool"], ev["args"], {"status": "executed", "result": _unwrap(output)}, None, session=ev["session"])
|
|
56
|
+
|
|
57
|
+
def on_tool_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
|
|
58
|
+
ev = self._pending.pop(run_id, None)
|
|
59
|
+
if ev is None:
|
|
60
|
+
return
|
|
61
|
+
self.client.record(ev["tool"], ev["args"], {"status": "error", "error": str(error)}, None, session=ev["session"])
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""OpenAI Agents SDK (Python) adapter: wraps each FunctionTool's on_invoke_tool. Decides, runs, records.
|
|
2
|
+
|
|
3
|
+
A denied call never runs; the model receives the denial text as the tool result and the run continues.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import dataclasses
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any, Dict, List
|
|
10
|
+
|
|
11
|
+
from agents import FunctionTool
|
|
12
|
+
|
|
13
|
+
from . import Client, receipt_id_of
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _parse(input_json: str) -> Dict[str, Any]:
|
|
17
|
+
try:
|
|
18
|
+
v = json.loads(input_json) if input_json else {}
|
|
19
|
+
return v if isinstance(v, dict) else {"input": v}
|
|
20
|
+
except ValueError:
|
|
21
|
+
return {"input": input_json}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _result(v: Any) -> Any:
|
|
25
|
+
if not isinstance(v, str):
|
|
26
|
+
return v
|
|
27
|
+
try:
|
|
28
|
+
return json.loads(v)
|
|
29
|
+
except ValueError:
|
|
30
|
+
return v
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def wrap_tools(client: Client, tools: List[FunctionTool]) -> List[FunctionTool]:
|
|
34
|
+
out: List[FunctionTool] = []
|
|
35
|
+
for t in tools:
|
|
36
|
+
original = t.on_invoke_tool
|
|
37
|
+
|
|
38
|
+
async def invoke(ctx: Any, input_json: str, _t: FunctionTool = t, _orig=original) -> Any:
|
|
39
|
+
args = _parse(input_json)
|
|
40
|
+
policy = client.decide(_t.name, args)
|
|
41
|
+
if policy and policy["decision"] == "deny":
|
|
42
|
+
reason = "; ".join(policy["reasons"] + policy["errors"]) or "no permit policy matched"
|
|
43
|
+
bundle = client.record(_t.name, args, {"status": "denied", "reason": reason}, policy)
|
|
44
|
+
return f"Denied by policy: {reason} (receipt {receipt_id_of(bundle)})"
|
|
45
|
+
try:
|
|
46
|
+
result = await _orig(ctx, input_json)
|
|
47
|
+
except Exception as e:
|
|
48
|
+
client.record(_t.name, args, {"status": "error", "error": str(e)}, policy)
|
|
49
|
+
raise
|
|
50
|
+
client.record(_t.name, args, {"status": "executed", "result": _result(result)}, policy)
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
out.append(dataclasses.replace(t, on_invoke_tool=invoke))
|
|
54
|
+
return out
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agent-custody
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Signed, verifiable receipts for AI agent tool calls, from Python, through the agent-custody sidecar
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Provides-Extra: langchain
|
|
8
|
+
Requires-Dist: langchain-core>=0.3; extra == 'langchain'
|
|
9
|
+
Provides-Extra: openai-agents
|
|
10
|
+
Requires-Dist: openai-agents>=0.1; extra == 'openai-agents'
|
|
11
|
+
Provides-Extra: test
|
|
12
|
+
Requires-Dist: langchain-core>=0.3; extra == 'test'
|
|
13
|
+
Requires-Dist: openai-agents>=0.1; extra == 'test'
|
|
14
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# agent-custody (Python)
|
|
18
|
+
|
|
19
|
+
Signed, verifiable receipts for AI agent tool calls, from Python. The key, the Cedar policy, and the Merkle log live in the agent-custody sidecar, a local process from the npm package; this client talks to it over HTTP with the standard library only.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install -g @agent-custody/receipts && agent-custody keygen --dir keys --name app
|
|
23
|
+
agent-custody serve --config sdk.json # loopback, port 8788
|
|
24
|
+
pip install agent-custody
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from agent_custody import Client, PolicyDeniedError
|
|
29
|
+
|
|
30
|
+
client = Client() # http://127.0.0.1:8788/
|
|
31
|
+
refund = client.wrap("stripe.refund", lambda args: stripe.refund(**args))
|
|
32
|
+
refund({"amount": 5000}) # decide, run, record; raises PolicyDeniedError on deny
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Adapters, each tested against the real package: `agent_custody.langchain.ReceiptCallbackHandler` (record-only), `agent_custody.openai_agents.wrap_tools` (enforce and record), `agent_custody.claude_agent_sdk.claude_hook` (PreToolUse deny, PostToolUse record). Receipts are verified by the TypeScript verifier; the tests do exactly that.
|
|
36
|
+
|
|
37
|
+
Everything recorded is `claimed`: the sidecar trusts what this process reports, the same as the in-process TypeScript SDK. For enforcement the agent cannot skip, put the gateway in front of the tools instead; it is an MCP server and needs nothing from this package.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uv run --extra test pytest # from packages/python; starts a sidecar with node from ../receipts
|
|
41
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
agent_custody/__init__.py,sha256=XQU2PRLXtEOzWsxeLwmVEkBcKjFcldS4gAm6o04JnWE,4617
|
|
2
|
+
agent_custody/claude_agent_sdk.py,sha256=v2CAl1AGzLm5s6c7qXuV3JcAEqpjKnYawWwRwglgNnA,2605
|
|
3
|
+
agent_custody/langchain.py,sha256=IbkQVcvvOXdkKD2hsYGP61CNh_1p06VzhMCd4agHz4w,2459
|
|
4
|
+
agent_custody/openai_agents.py,sha256=i6CruYcPJdD4cko2RFiuYEiBG62l6rLGBojkDYYP-3E,1908
|
|
5
|
+
agent_custody-0.1.2.dist-info/METADATA,sha256=Z4RkprXTZNffY47ytSkmuR8lZrSFWxedMOB37_L35pw,2110
|
|
6
|
+
agent_custody-0.1.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
7
|
+
agent_custody-0.1.2.dist-info/RECORD,,
|