agent-custody 0.1.2__tar.gz
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.
- agent_custody-0.1.2/.gitignore +4 -0
- agent_custody-0.1.2/PKG-INFO +41 -0
- agent_custody-0.1.2/README.md +25 -0
- agent_custody-0.1.2/agent_custody/__init__.py +95 -0
- agent_custody-0.1.2/agent_custody/claude_agent_sdk.py +47 -0
- agent_custody-0.1.2/agent_custody/langchain.py +61 -0
- agent_custody-0.1.2/agent_custody/openai_agents.py +54 -0
- agent_custody-0.1.2/pyproject.toml +23 -0
- agent_custody-0.1.2/tests/conftest.py +48 -0
- agent_custody-0.1.2/tests/test_claude_hooks.py +19 -0
- agent_custody-0.1.2/tests/test_client.py +55 -0
- agent_custody-0.1.2/tests/test_langchain.py +31 -0
- agent_custody-0.1.2/tests/test_openai_agents.py +26 -0
- agent_custody-0.1.2/uv.lock +2855 -0
|
@@ -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,25 @@
|
|
|
1
|
+
# agent-custody (Python)
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -g @agent-custody/receipts && agent-custody keygen --dir keys --name app
|
|
7
|
+
agent-custody serve --config sdk.json # loopback, port 8788
|
|
8
|
+
pip install agent-custody
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from agent_custody import Client, PolicyDeniedError
|
|
13
|
+
|
|
14
|
+
client = Client() # http://127.0.0.1:8788/
|
|
15
|
+
refund = client.wrap("stripe.refund", lambda args: stripe.refund(**args))
|
|
16
|
+
refund({"amount": 5000}) # decide, run, record; raises PolicyDeniedError on deny
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
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.
|
|
20
|
+
|
|
21
|
+
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.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv run --extra test pytest # from packages/python; starts a sidecar with node from ../receipts
|
|
25
|
+
```
|
|
@@ -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,23 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "agent-custody"
|
|
3
|
+
version = "0.1.2"
|
|
4
|
+
description = "Signed, verifiable receipts for AI agent tool calls, from Python, through the agent-custody sidecar"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
requires-python = ">=3.9"
|
|
8
|
+
dependencies = []
|
|
9
|
+
|
|
10
|
+
[project.optional-dependencies]
|
|
11
|
+
langchain = ["langchain-core>=0.3"]
|
|
12
|
+
openai-agents = ["openai-agents>=0.1"]
|
|
13
|
+
test = ["pytest>=8", "langchain-core>=0.3", "openai-agents>=0.1"]
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["hatchling"]
|
|
17
|
+
build-backend = "hatchling.build"
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["agent_custody"]
|
|
21
|
+
|
|
22
|
+
[tool.pytest.ini_options]
|
|
23
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Every test runs against a real sidecar: `node ../receipts/src/cli.ts serve`, started here on a free port with an
|
|
2
|
+
# application key and a policy generated for the session. No mocks of the sidecar; it is the thing being integrated.
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from agent_custody import Client
|
|
14
|
+
|
|
15
|
+
RECEIPTS = Path(__file__).resolve().parents[2] / "receipts"
|
|
16
|
+
POLICY = 'permit(principal, action == Action::"customer.lookup", resource);\npermit(principal, action == Action::"stripe.refund", resource) when { context.args.amount <= 100000 };\n'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture(scope="session")
|
|
20
|
+
def sidecar():
|
|
21
|
+
node = shutil.which("node")
|
|
22
|
+
assert node, "node is required: the sidecar is the agent-custody CLI"
|
|
23
|
+
d = Path(tempfile.mkdtemp(prefix="agent-custody-py-"))
|
|
24
|
+
subprocess.run([node, str(RECEIPTS / "src/cli.ts"), "keygen", "--dir", str(d / "keys"), "--name", "app"], check=True, capture_output=True)
|
|
25
|
+
(d / "policy.cedar").write_text(POLICY)
|
|
26
|
+
(d / "sdk.json").write_text(json.dumps({"agentId": "py-bot", "principalId": "user_456", "identity": {"keyFile": "keys/app.key"}, "policyFile": "policy.cedar", "receiptsDir": "receipts", "logFile": "log.jsonl", "framework": "python"}))
|
|
27
|
+
p = subprocess.Popen([node, str(RECEIPTS / "src/cli.ts"), "serve", "--config", str(d / "sdk.json"), "--port", "0"], stderr=subprocess.PIPE, text=True)
|
|
28
|
+
line = p.stderr.readline()
|
|
29
|
+
m = re.search(r"(http://[^ ]+)", line)
|
|
30
|
+
assert m, f"sidecar did not start: {line}"
|
|
31
|
+
yield {"url": m.group(1), "dir": d, "receipts": d / "receipts", "app_pub": d / "keys" / "app.pub", "log": d / "log.jsonl", "node": node}
|
|
32
|
+
p.terminate()
|
|
33
|
+
p.wait(timeout=10)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.fixture
|
|
37
|
+
def client(sidecar):
|
|
38
|
+
return Client(sidecar["url"])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def receipt_count(sidecar) -> int:
|
|
42
|
+
return len(list(sidecar["receipts"].glob("*.json"))) if sidecar["receipts"].exists() else 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def verify(sidecar, receipt_id: str) -> dict:
|
|
46
|
+
"""The TypeScript verifier is the reference; a receipt from Python must pass it."""
|
|
47
|
+
out = subprocess.run([sidecar["node"], str(RECEIPTS / "src/cli.ts"), "verify", str(sidecar["receipts"] / f"{receipt_id}.json"), "--issuer-key", str(sidecar["app_pub"]), "--log", str(sidecar["log"]), "--json"], capture_output=True, text=True)
|
|
48
|
+
return json.loads(out.stdout)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# The documented Claude Agent SDK hook contract, driven the way the SDK drives it.
|
|
2
|
+
import asyncio
|
|
3
|
+
|
|
4
|
+
from agent_custody.claude_agent_sdk import claude_hook, handle_hook_event
|
|
5
|
+
from conftest import receipt_count, verify
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_pretooluse_denies_with_a_receipt_and_never_auto_approves(client, sidecar):
|
|
9
|
+
out = handle_hook_event(client, {"hook_event_name": "PreToolUse", "session_id": "s1", "tool_use_id": "t1", "tool_name": "stripe.refund", "tool_input": {"amount": 999999}})
|
|
10
|
+
assert out["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
11
|
+
rid = out["hookSpecificOutput"]["permissionDecisionReason"].split("receipt ")[1].rstrip(")")
|
|
12
|
+
assert verify(sidecar, rid)["statement"]["predicate"]["execution"]["status"] == "denied"
|
|
13
|
+
assert handle_hook_event(client, {"hook_event_name": "PreToolUse", "tool_name": "stripe.refund", "tool_input": {"amount": 1}}) == {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_posttooluse_records_through_the_async_hook_callable(client, sidecar):
|
|
17
|
+
before = receipt_count(sidecar)
|
|
18
|
+
out = asyncio.run(claude_hook(client)({"hook_event_name": "PostToolUse", "tool_name": "customer.lookup", "tool_input": {"id": "c1"}, "tool_response": {"name": "Dana"}}, "t2", None))
|
|
19
|
+
assert out == {} and receipt_count(sidecar) == before + 1
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from agent_custody import PolicyDeniedError, SidecarError, receipt_id_of
|
|
4
|
+
from conftest import receipt_count, verify
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_health_names_the_agent(client):
|
|
8
|
+
h = client.health()
|
|
9
|
+
assert h["agentId"] == "py-bot" and h["log"]["kind"] == "file"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_decide_runs_the_policy_on_args(client):
|
|
13
|
+
assert client.decide("stripe.refund", {"amount": 500})["decision"] == "allow"
|
|
14
|
+
assert client.decide("stripe.refund", {"amount": 500000})["decision"] == "deny"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_record_produces_a_receipt_the_typescript_verifier_accepts(client, sidecar):
|
|
18
|
+
policy = client.decide("stripe.refund", {"amount": 500})
|
|
19
|
+
bundle = client.record("stripe.refund", {"amount": 500}, {"status": "executed", "result": {"refund_id": "re_1"}}, policy, session={"id": "s1", "toolUseId": "t1"})
|
|
20
|
+
v = verify(sidecar, receipt_id_of(bundle))
|
|
21
|
+
assert v["ok"], [c for c in v["checks"] if not c["ok"]]
|
|
22
|
+
p = v["statement"]["predicate"]
|
|
23
|
+
assert p["issuer"]["kind"] == "sdk" and p["issuer"]["framework"] == "python"
|
|
24
|
+
assert p["session"] == {"id": "s1", "toolUseId": "t1", "provenance": "claimed"}
|
|
25
|
+
assert p["policy"]["decision"] == "allow"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_wrap_decides_runs_records_and_denies(client, sidecar):
|
|
29
|
+
calls = []
|
|
30
|
+
refund = client.wrap("stripe.refund", lambda args: calls.append(args) or {"ok": True, **args})
|
|
31
|
+
before = receipt_count(sidecar)
|
|
32
|
+
assert refund({"amount": 500}) == {"ok": True, "amount": 500}
|
|
33
|
+
with pytest.raises(PolicyDeniedError) as e:
|
|
34
|
+
refund({"amount": 500000})
|
|
35
|
+
assert calls == [{"amount": 500}], "the denied call never ran"
|
|
36
|
+
assert receipt_count(sidecar) == before + 2
|
|
37
|
+
assert verify(sidecar, e.value.receipt_id)["statement"]["predicate"]["execution"]["status"] == "denied"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_wrap_records_an_error_and_rethrows(client, sidecar):
|
|
41
|
+
def boom(args):
|
|
42
|
+
raise RuntimeError("upstream down")
|
|
43
|
+
|
|
44
|
+
lookup = client.wrap("customer.lookup", boom)
|
|
45
|
+
before = receipt_count(sidecar)
|
|
46
|
+
with pytest.raises(RuntimeError, match="upstream down"):
|
|
47
|
+
lookup({"id": "c1"})
|
|
48
|
+
assert receipt_count(sidecar) == before + 1
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_a_malformed_record_is_refused_and_nothing_is_written(client, sidecar):
|
|
52
|
+
before = receipt_count(sidecar)
|
|
53
|
+
with pytest.raises(SidecarError, match="400"):
|
|
54
|
+
client.record("t", {}, {"status": "maybe"})
|
|
55
|
+
assert receipt_count(sidecar) == before
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Against the real langchain-core: a StructuredTool invoked with the handler in the config. No model, no network.
|
|
2
|
+
from langchain_core.tools import tool
|
|
3
|
+
|
|
4
|
+
from agent_custody.langchain import ReceiptCallbackHandler
|
|
5
|
+
from conftest import receipt_count, verify
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@tool
|
|
9
|
+
def refund(amount: int) -> dict:
|
|
10
|
+
"""Refund an amount."""
|
|
11
|
+
return {"refund_id": "re_lc", "amount": amount}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@tool
|
|
15
|
+
def flaky(id: str) -> dict:
|
|
16
|
+
"""Always fails."""
|
|
17
|
+
raise RuntimeError("upstream down")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_every_tool_run_gets_a_receipt(client, sidecar):
|
|
21
|
+
handler = ReceiptCallbackHandler(client)
|
|
22
|
+
before = receipt_count(sidecar)
|
|
23
|
+
assert refund.invoke({"amount": 7}, config={"callbacks": [handler]}) == {"refund_id": "re_lc", "amount": 7}
|
|
24
|
+
try:
|
|
25
|
+
flaky.invoke({"id": "c1"}, config={"callbacks": [handler]})
|
|
26
|
+
except RuntimeError:
|
|
27
|
+
pass
|
|
28
|
+
assert receipt_count(sidecar) == before + 2
|
|
29
|
+
newest = sorted(sidecar["receipts"].glob("*.json"), key=lambda p: p.stat().st_mtime)[-2:]
|
|
30
|
+
statuses = {verify(sidecar, p.stem)["statement"]["predicate"]["execution"]["status"] for p in newest}
|
|
31
|
+
assert statuses == {"executed", "error"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Against the real openai-agents package: a FunctionTool built by @function_tool, invoked the way the runner invokes it.
|
|
2
|
+
import asyncio
|
|
3
|
+
|
|
4
|
+
from agents import RunConfig, function_tool
|
|
5
|
+
from agents.tool_context import ToolContext
|
|
6
|
+
|
|
7
|
+
from agent_custody.openai_agents import wrap_tools
|
|
8
|
+
from conftest import receipt_count, verify
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@function_tool(name_override="stripe.refund")
|
|
12
|
+
def refund(amount: int) -> dict:
|
|
13
|
+
"""Refund an amount."""
|
|
14
|
+
return {"refund_id": "re_oa", "amount": amount}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_wrapped_tool_enforces_and_records(client, sidecar):
|
|
18
|
+
wrapped = wrap_tools(client, [refund])[0]
|
|
19
|
+
ctx = lambda input_json: ToolContext(context=None, tool_name="stripe.refund", tool_call_id="call-1", tool_arguments=input_json, run_config=RunConfig())
|
|
20
|
+
before = receipt_count(sidecar)
|
|
21
|
+
ok = asyncio.run(wrapped.on_invoke_tool(ctx('{"amount": 9}'), '{"amount": 9}'))
|
|
22
|
+
denied = asyncio.run(wrapped.on_invoke_tool(ctx('{"amount": 900000}'), '{"amount": 900000}'))
|
|
23
|
+
assert "re_oa" in str(ok)
|
|
24
|
+
assert str(denied).startswith("Denied by policy:")
|
|
25
|
+
assert receipt_count(sidecar) == before + 2
|
|
26
|
+
assert wrapped.name == refund.name and wrapped.params_json_schema == refund.params_json_schema
|