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.
@@ -0,0 +1,4 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
@@ -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