trooth-verify 0.4.0__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,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: trooth-verify
3
+ Version: 0.4.0
4
+ Summary: Check a Trooth witness statement yourself: Ed25519 signature, key lifecycle, subject, counts, mapping and manifest digests, offline bundles. The rules of docs/VERIFY.md. Also trooth_guard, the guard for Python agent frameworks.
5
+ Author: Trooth, LLC
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://trooth.co
8
+ Project-URL: Source, https://github.com/troothllc/trooth-cli/tree/main/sdk/python
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: cryptography>=42
12
+ Provides-Extra: models
13
+ Requires-Dist: pydantic>=2.5; extra == "models"
14
+
15
+ # trooth-verify (Python)
16
+
17
+ Check a Trooth witness statement yourself, with no trust in Trooth's website or API. A port of `trooth verify` that follows [docs/VERIFY.md](../../docs/VERIFY.md) and passes every case in [tests/vectors](../../tests/vectors).
18
+
19
+ ```
20
+ pip install "git+https://github.com/troothllc/trooth-cli#subdirectory=sdk/python"
21
+ ```
22
+
23
+ ```python
24
+ import json
25
+ from trooth_verify import verify_bundle
26
+
27
+ bundle = json.load(open("trooth.co.bundle.json")) # written by: trooth verify trooth.co --save-bundle trooth.co.bundle.json
28
+ result = verify_bundle(bundle)
29
+ print(result["verdict"]) # checked, checked_v1, partially_checked, signature_not_trusted or mismatch
30
+ ```
31
+
32
+ `verify_statement(statement, keys, mapping_bytes, manifest, domain)` checks inputs you read yourself. The result has the shape of [schemas/verify-result.schema.json](../../schemas/verify-result.schema.json). `trooth_verify.models` holds Pydantic v2 models generated from [schemas/](../../schemas) (install with the `models` extra).
33
+
34
+ Bundles written by `trooth` 0.9.0 and later carry the witness statement log's answer; the receipt and any correction are checked too, and a valid correction gives the verdict `superseded` ([docs/LOG.md](../../docs/LOG.md)). Pass your own pinned log key to replace the one a bundle carries.
35
+
36
+ A `checked` verdict means Trooth's key signed these outcome bytes for this domain, bound to the exact check mapping and evidence manifest. It does not establish the company's identity, an independent time, or that the company is safe, compliant or authorized for anything.
37
+
38
+ Run the tests from this folder: `python3 -m unittest discover -s tests`. Licensed under the Apache License 2.0.
@@ -0,0 +1,24 @@
1
+ # trooth-verify (Python)
2
+
3
+ Check a Trooth witness statement yourself, with no trust in Trooth's website or API. A port of `trooth verify` that follows [docs/VERIFY.md](../../docs/VERIFY.md) and passes every case in [tests/vectors](../../tests/vectors).
4
+
5
+ ```
6
+ pip install "git+https://github.com/troothllc/trooth-cli#subdirectory=sdk/python"
7
+ ```
8
+
9
+ ```python
10
+ import json
11
+ from trooth_verify import verify_bundle
12
+
13
+ bundle = json.load(open("trooth.co.bundle.json")) # written by: trooth verify trooth.co --save-bundle trooth.co.bundle.json
14
+ result = verify_bundle(bundle)
15
+ print(result["verdict"]) # checked, checked_v1, partially_checked, signature_not_trusted or mismatch
16
+ ```
17
+
18
+ `verify_statement(statement, keys, mapping_bytes, manifest, domain)` checks inputs you read yourself. The result has the shape of [schemas/verify-result.schema.json](../../schemas/verify-result.schema.json). `trooth_verify.models` holds Pydantic v2 models generated from [schemas/](../../schemas) (install with the `models` extra).
19
+
20
+ Bundles written by `trooth` 0.9.0 and later carry the witness statement log's answer; the receipt and any correction are checked too, and a valid correction gives the verdict `superseded` ([docs/LOG.md](../../docs/LOG.md)). Pass your own pinned log key to replace the one a bundle carries.
21
+
22
+ A `checked` verdict means Trooth's key signed these outcome bytes for this domain, bound to the exact check mapping and evidence manifest. It does not establish the company's identity, an independent time, or that the company is safe, compliant or authorized for anything.
23
+
24
+ Run the tests from this folder: `python3 -m unittest discover -s tests`. Licensed under the Apache License 2.0.
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "trooth-verify"
7
+ version = "0.4.0"
8
+ description = "Check a Trooth witness statement yourself: Ed25519 signature, key lifecycle, subject, counts, mapping and manifest digests, offline bundles. The rules of docs/VERIFY.md. Also trooth_guard, the guard for Python agent frameworks."
9
+ readme = "README.md"
10
+ license = { text = "Apache-2.0" }
11
+ authors = [{ name = "Trooth, LLC" }]
12
+ requires-python = ">=3.9"
13
+ dependencies = ["cryptography>=42"]
14
+
15
+ [project.optional-dependencies]
16
+ models = ["pydantic>=2.5"]
17
+
18
+ [project.urls]
19
+ Homepage = "https://trooth.co"
20
+ Source = "https://github.com/troothllc/trooth-cli/tree/main/sdk/python"
21
+
22
+ [tool.setuptools]
23
+ packages = ["trooth_verify", "trooth_guard"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,341 @@
1
+ """trooth_guard against a fake `trooth guard decide` (fake_trooth.py): no network, no npm,
2
+ no framework package. Framework pieces (interrupt, ToolMessage, Command, contexts) are
3
+ small fakes in the shapes the frameworks document."""
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import pathlib
9
+ import sys
10
+ import tempfile
11
+ import unittest
12
+ from types import SimpleNamespace
13
+
14
+ HERE = pathlib.Path(__file__).resolve().parent
15
+ sys.path.insert(0, str(HERE.parent))
16
+
17
+ from trooth_guard import ( # noqa: E402
18
+ TROOTH_VERSION,
19
+ GuardDeny,
20
+ GuardError,
21
+ GuardHold,
22
+ covered,
23
+ decide,
24
+ decide_tool_call,
25
+ describe,
26
+ error_for,
27
+ fail_closed,
28
+ is_approval,
29
+ trooth_command,
30
+ )
31
+ from trooth_guard import crewai as tg_crewai # noqa: E402
32
+ from trooth_guard import langchain as tg_langchain # noqa: E402
33
+ from trooth_guard import langgraph as tg_langgraph # noqa: E402
34
+ from trooth_guard import openai_agents as tg_openai # noqa: E402
35
+
36
+ FAKE = [sys.executable, str(HERE / "fake_trooth.py")]
37
+ KW = {"trooth_cmd": FAKE}
38
+ POLICY = "policy.yaml" # the fake never opens it
39
+
40
+
41
+ class FakeToolMessage:
42
+ def __init__(self, **kw):
43
+ self.__dict__.update(kw)
44
+
45
+
46
+ class FakeCommand:
47
+ def __init__(self, **kw):
48
+ self.__dict__.update(kw)
49
+
50
+
51
+ class Core(unittest.TestCase):
52
+ def test_exit_codes_map_to_decisions(self):
53
+ self.assertEqual(decide(POLICY, "pay.out", "good.example", **KW)["decision"], "allow")
54
+ self.assertEqual(decide(POLICY, "pay.out", "unknown.example", **KW)["decision"], "hold")
55
+ d = decide(POLICY, "pay.out", "bad.example", **KW)
56
+ self.assertEqual(d["decision"], "deny")
57
+ self.assertEqual(d["reasons"][0]["code"], "SIGNATURE_INVALID")
58
+
59
+ def test_uncovered_tool_decides_nothing(self):
60
+ self.assertIsNone(decide(POLICY, "uncovered.tool", "bad.example", **KW))
61
+ ok, out = tg_crewai.task_guardrail(POLICY, "uncovered.tool", extract=lambda o: {"host": "bad.example"}, **KW)("x")
62
+ self.assertTrue(ok)
63
+ self.assertEqual(out, "x")
64
+
65
+ def test_failures_hold_never_allow(self):
66
+ for host, why in [("crash.example", "exited 3"), ("garbage.example", "not JSON"), ("mismatch.example", "exit code says hold")]:
67
+ with self.subTest(host):
68
+ d = decide(POLICY, "pay.out", host, **KW)
69
+ self.assertEqual(d["decision"], "hold")
70
+ self.assertEqual(d["reasons"][0]["code"], "SOURCE_UNREACHABLE")
71
+ self.assertIn(why, d["reasons"][0]["detail"])
72
+ d = decide(POLICY, "pay.out", "slow.example", timeout=0.5, **KW)
73
+ self.assertEqual((d["decision"], d["reasons"][0]["code"]), ("hold", "SOURCE_UNREACHABLE"))
74
+ d = decide(POLICY, "pay.out", "good.example", trooth_cmd=["/nonexistent/trooth-cli-for-test"])
75
+ self.assertEqual((d["decision"], d["reasons"][0]["code"]), ("hold", "SOURCE_UNREACHABLE"))
76
+ self.assertIn("not found", d["reasons"][0]["detail"])
77
+ d = decide(POLICY, "pay.out", "good.example", trooth_cmd=[sys.executable, "-c", "import sys; print('{\"decision\": \"allow\"}'); sys.exit(0)"])
78
+ self.assertEqual(d["decision"], "hold", "an allow without reasons is not a Decision")
79
+
80
+ def test_command_line(self):
81
+ with tempfile.TemporaryDirectory() as t:
82
+ log = os.path.join(t, "argv.jsonl")
83
+ os.environ["FAKE_TROOTH_LOG"] = log
84
+ try:
85
+ decide(POLICY, "pay.out", "good.example", {"amount": 5}, cache=t, offline=True, **KW)
86
+ decide_tool_call(POLICY, "send_email", {"to": "ap@good.example"}, **KW)
87
+ finally:
88
+ del os.environ["FAKE_TROOTH_LOG"]
89
+ a, b = [json.loads(x) for x in pathlib.Path(log).read_text().splitlines()]
90
+ self.assertEqual(a, ["guard", "decide", "--policy", POLICY, "--tool", "pay.out", "--host", "good.example", "--args", '{"amount":5}', "--offline", "--cache", t, "--json"])
91
+ self.assertEqual(b, ["guard", "decide", "--policy", POLICY, "--tool", "send_email", "--args", '{"to":"ap@good.example"}', "--json"])
92
+
93
+ def test_trooth_command(self):
94
+ old = os.environ.pop("TROOTH_CMD", None)
95
+ try:
96
+ self.assertEqual(trooth_command(), ["npx", "--yes", f"trooth@{TROOTH_VERSION}"])
97
+ os.environ["TROOTH_CMD"] = "node /opt/trooth/bin/trooth.mjs"
98
+ self.assertEqual(trooth_command(), ["node", "/opt/trooth/bin/trooth.mjs"])
99
+ self.assertEqual(trooth_command(["x", "y"]), ["x", "y"], "the argument wins over the environment")
100
+ self.assertEqual(decide(POLICY, "pay.out", "good.example", trooth_cmd=" ".join(FAKE))["decision"], "allow")
101
+ with self.assertRaises(ValueError):
102
+ trooth_command("")
103
+ finally:
104
+ os.environ.pop("TROOTH_CMD", None)
105
+ if old is not None:
106
+ os.environ["TROOTH_CMD"] = old
107
+
108
+ def test_errors_and_helpers(self):
109
+ h = error_for(decide(POLICY, "pay.out", "unknown.example", **KW))
110
+ self.assertIsInstance(h, GuardHold)
111
+ self.assertIsInstance(h, GuardError)
112
+ self.assertEqual(h.reasons, ["EVIDENCE_MISSING"])
113
+ self.assertIsInstance(error_for(decide(POLICY, "pay.out", "bad.example", **KW)), GuardDeny)
114
+ self.assertIsNone(error_for(decide(POLICY, "pay.out", "good.example", **KW)))
115
+ self.assertIsInstance(error_for({}), GuardHold)
116
+ m = describe(decide(POLICY, "pay.out", "unknown.example", **KW))
117
+ self.assertTrue(m.startswith("Trooth guard: hold the call to pay.out for unknown.example under policy vendor-payments version 3."), m)
118
+ for v in ("allow", "hold", "deny"):
119
+ s = describe({"decision": v, "reasons": [], "action": {"tool": "t", "host": "h.example"}})
120
+ for bad in ("safe", "verified", "trusted", "score", "!", "n't"):
121
+ self.assertNotIn(bad, s.lower())
122
+ f = fail_closed("t", None, "x")
123
+ self.assertEqual((f["decision"], f["subject"]), ("hold", ""))
124
+ for yes in (True, "approve", {"type": "approve"}, {"approved": True}, {"decisions": [{"type": "approve"}]}):
125
+ self.assertTrue(is_approval(yes), yes)
126
+ for no in (None, False, "yes", {"type": "reject"}, {"decisions": []}, {"decisions": [{"type": "approve"}, {"type": "edit"}]}):
127
+ self.assertFalse(is_approval(no), no)
128
+ self.assertTrue(covered("pay.out", None))
129
+ self.assertTrue(covered("mcp__bank__send", ["pay.*", "mcp__bank__*"]))
130
+ self.assertFalse(covered("search", ["pay.*"]))
131
+ self.assertFalse(covered(None, ["*"]))
132
+
133
+ def test_covered_matches_the_cli_rule(self):
134
+ # The CLI covers these (bin/lib/guard-policy.mjs toolCovered); with tools= given,
135
+ # an adapter that said "not covered" would run the call without asking.
136
+ tools = ["stripe.create_payout", "mcp__bank__*"]
137
+ for name in ("Stripe.create_payout", "STRIPE.CREATE_PAYOUT", "stripe.create_payout", "stripe.create_payout ", "mcp__BANK__pay", "mcp__bank__x\n"):
138
+ self.assertTrue(covered(name, tools), repr(name))
139
+ self.assertFalse(covered("stripe.list_payouts", tools))
140
+ self.assertFalse(covered("mcp__bank", tools))
141
+ self.assertTrue(covered("a[b]", ["a[b]"]), "only * is a wildcard; [ ] are literal")
142
+ self.assertFalse(covered("a.b", ["a?b"]), "? is literal, as in the CLI")
143
+ hook = tg_crewai.before_tool_call_hook(POLICY, tools=["pay.*"], **KW)
144
+ self.assertIs(hook(SimpleNamespace(tool_name="PAY.out", tool_input={"url": "bad.example"})), False)
145
+
146
+
147
+ class CrewAI(unittest.TestCase):
148
+ def test_task_guardrail(self):
149
+ g = tg_crewai.task_guardrail(POLICY, "pay.out", **KW)
150
+ # CrewAI's Task validator reads the return annotation with typing.get_origin; it must be the real type, not a string.
151
+ import inspect
152
+ import typing
153
+ ann = inspect.signature(g).return_annotation
154
+ self.assertIs(typing.get_origin(ann), tuple)
155
+ self.assertEqual(typing.get_args(ann), (bool, typing.Any))
156
+ out = SimpleNamespace(raw=json.dumps({"url": "https://good.example/pay"}), json_dict=None, pydantic=None)
157
+ self.assertEqual(g(out), (True, out))
158
+ ok, msg = g(SimpleNamespace(raw='{"url": "https://bad.example/x"}', json_dict=None, pydantic=None))
159
+ self.assertFalse(ok)
160
+ self.assertIn("deny", msg)
161
+ ok, msg = g(SimpleNamespace(raw="", json_dict={"url": "unknown.example"}, pydantic=None))
162
+ self.assertFalse(ok)
163
+ self.assertIn("A person must approve it", msg)
164
+ asked = []
165
+ approve = tg_crewai.task_guardrail(POLICY, "pay.out", ask=lambda d: asked.append(d) or True, **KW)
166
+ self.assertTrue(approve(SimpleNamespace(raw='{"url": "unknown.example"}', json_dict=None, pydantic=None))[0])
167
+ self.assertTrue(approve(SimpleNamespace(raw='{"url": "bad.example"}', json_dict=None, pydantic=None))[0] is False, "a person cannot approve a deny")
168
+ self.assertEqual(len(asked), 1)
169
+ skip = tg_crewai.task_guardrail(POLICY, "pay.out", extract=lambda o: None, **KW)
170
+ self.assertEqual(skip("x"), (True, "x"))
171
+ broken = tg_crewai.task_guardrail(POLICY, "pay.out", extract=lambda o: 1 / 0, **KW)
172
+ self.assertFalse(broken("x")[0])
173
+
174
+ def test_before_tool_call_hook(self):
175
+ seen = []
176
+ hook = tg_crewai.before_tool_call_hook(POLICY, tools=["pay.*"], on_decision=lambda d: seen.append(d["decision"]), **KW)
177
+ ctx = lambda name, inp: SimpleNamespace(tool_name=name, tool_input=inp) # noqa: E731
178
+ self.assertIsNone(hook(ctx("pay.out", {"url": "good.example"})))
179
+ self.assertIs(hook(ctx("pay.out", {"url": "bad.example"})), False)
180
+ self.assertIs(hook(ctx("pay.out", {"url": "unknown.example"})), False)
181
+ self.assertIs(hook(ctx("pay.out", {"url": "crash.example"})), False)
182
+ self.assertIsNone(hook(ctx("search", {"q": "x"})), "an uncovered tool runs without asking")
183
+ self.assertEqual(seen, ["allow", "deny", "hold", "hold"])
184
+ ask = tg_crewai.before_tool_call_hook(POLICY, ask=lambda d: True, **KW)
185
+ self.assertIsNone(ask(ctx("pay.out", {"url": "unknown.example"})))
186
+ self.assertIs(ask(ctx("pay.out", {"url": "bad.example"})), False)
187
+
188
+
189
+ class LangGraph(unittest.TestCase):
190
+ def state(self, *calls):
191
+ return {"messages": [{"type": "human"}, SimpleNamespace(tool_calls=list(calls))]}
192
+
193
+ def test_allow_and_skip(self):
194
+ node = tg_langgraph.guard_node(POLICY, interrupt=lambda p: self.fail("no interrupt"), tools=["pay.*"], **KW)
195
+ self.assertEqual(node(self.state({"name": "pay.out", "args": {"url": "good.example"}, "id": "a"}, {"name": "search", "args": {}, "id": "b"})), {})
196
+ self.assertEqual(node({"messages": []}), {})
197
+ keyed = tg_langgraph.guard_node(POLICY, interrupt=lambda p: None, decisions_key="trooth", **KW)
198
+ u = keyed(self.state({"name": "pay.out", "args": {"url": "good.example"}, "id": "a"}))
199
+ self.assertEqual(u["trooth"][0]["decision"]["decision"], "allow")
200
+ with self.assertRaises(TypeError):
201
+ tg_langgraph.guard_node(POLICY, interrupt=lambda p: None, deny_goto="x")
202
+ # Routing without tools_goto is refused: a static edge would also run the tools after a routed deny.
203
+ with self.assertRaises(TypeError):
204
+ tg_langgraph.guard_node(POLICY, interrupt=lambda p: None, command=FakeCommand, deny_goto="x")
205
+ routed = tg_langgraph.guard_node(POLICY, interrupt=lambda p: self.fail("no interrupt"), command=FakeCommand, deny_goto="x", tools_goto="tools", **KW)
206
+ self.assertEqual(routed(self.state({"name": "pay.out", "args": {"url": "good.example"}, "id": "a"})).goto, "tools")
207
+
208
+ def test_hold_interrupts_once(self):
209
+ payloads = []
210
+ s = self.state({"name": "pay.out", "args": {"url": "unknown.example"}, "id": "a"}, {"name": "send_email", "args": {"to": "x@unknown.example"}, "id": "b"}, {"name": "pay.out", "args": {"url": "good.example"}, "id": "c"})
211
+ node = lambda resume, **kw: tg_langgraph.guard_node(POLICY, interrupt=lambda p: payloads.append(p) or resume, **kw, **KW) # noqa: E731
212
+ self.assertEqual(node({"type": "approve"})(s), {})
213
+ self.assertEqual(len(payloads), 1)
214
+ self.assertEqual(payloads[0]["type"], "trooth_guard_hold")
215
+ self.assertEqual([x["tool_call"]["id"] for x in payloads[0]["held"]], ["a", "b"])
216
+ with self.assertRaises(GuardHold):
217
+ node({"type": "reject"})(s)
218
+ c = node(False, command=FakeCommand, hold_goto="review", tools_goto="tools")(s)
219
+ self.assertEqual(c.goto, "review")
220
+
221
+ def test_deny_before_interrupt(self):
222
+ s = self.state({"name": "pay.out", "args": {"url": "unknown.example"}, "id": "a"}, {"name": "pay.out", "args": {"url": "bad.example"}, "id": "b"})
223
+ asked = []
224
+ with self.assertRaises(GuardDeny):
225
+ tg_langgraph.guard_node(POLICY, interrupt=lambda p: asked.append(p), **KW)(s)
226
+ c = tg_langgraph.guard_node(POLICY, interrupt=lambda p: asked.append(p), command=FakeCommand, deny_goto="blocked", tools_goto="tools", decisions_key="d", **KW)(s)
227
+ self.assertEqual((c.goto, len(c.update["d"])), ("blocked", 2))
228
+ self.assertEqual(asked, [])
229
+
230
+
231
+ class LangChain(unittest.TestCase):
232
+ def req(self, name, args, id_="tc1"):
233
+ return SimpleNamespace(tool_call={"name": name, "args": args, "id": id_}, tool=None, state={}, runtime=None)
234
+
235
+ def test_allow_deny_hold(self):
236
+ ran = []
237
+ handler = lambda r: ran.append(r.tool_call["name"]) or "ran" # noqa: E731
238
+ w = tg_langchain.tool_call_wrapper(POLICY, tools=["pay.*"], **KW)
239
+ self.assertEqual(w(self.req("pay.out", {"url": "good.example"}), handler), "ran")
240
+ self.assertEqual(w(self.req("search", {}), handler), "ran")
241
+ with self.assertRaises(GuardDeny):
242
+ w(self.req("pay.out", {"url": "bad.example"}), handler)
243
+ with self.assertRaises(GuardHold):
244
+ w(self.req("pay.out", {"url": "unknown.example"}), handler)
245
+ m = tg_langchain.tool_call_wrapper(POLICY, tool_message=FakeToolMessage, on_deny="message", **KW)
246
+ msg = m(self.req("pay.out", {"url": "bad.example"}, "id9"), handler)
247
+ self.assertEqual((msg.status, msg.tool_call_id, msg.name), ("error", "id9", "pay.out"))
248
+ self.assertIn("SIGNATURE_INVALID", msg.content)
249
+ msg = m(self.req("pay.out", {"url": "unknown.example"}), handler)
250
+ self.assertIn("A person must approve it", msg.content)
251
+ self.assertEqual(ran, ["pay.out", "search"])
252
+
253
+ def test_interrupt(self):
254
+ ran = []
255
+ handler = lambda r: ran.append(1) or "ran" # noqa: E731
256
+ payloads = []
257
+ mk = lambda resume, **kw: tg_langchain.tool_call_wrapper(POLICY, interrupt=lambda p: payloads.append(p) or resume, **kw, **KW) # noqa: E731
258
+ self.assertEqual(mk({"type": "approve"})(self.req("pay.out", {"url": "unknown.example"}), handler), "ran")
259
+ self.assertEqual(payloads[0]["tool_call"], {"name": "pay.out", "id": "tc1"})
260
+ with self.assertRaises(GuardHold):
261
+ mk({"type": "reject"})(self.req("pay.out", {"url": "unknown.example"}), handler)
262
+ msg = mk(None, tool_message=FakeToolMessage)(self.req("pay.out", {"url": "unknown.example"}), handler)
263
+ self.assertIn("did not approve", msg.content)
264
+ self.assertEqual(ran, [1])
265
+
266
+ class Pause(Exception):
267
+ pass
268
+
269
+ def pause(_p):
270
+ raise Pause()
271
+
272
+ with self.assertRaises(Pause):
273
+ tg_langchain.tool_call_wrapper(POLICY, interrupt=pause, **KW)(self.req("pay.out", {"url": "unknown.example"}), handler)
274
+ for bad in ({"on_hold": "interrupt"}, {"on_deny": "message"}, {"on_hold": "allow"}):
275
+ with self.assertRaises(ValueError):
276
+ tg_langchain.tool_call_wrapper(POLICY, **bad)
277
+
278
+ def test_async(self):
279
+ async def handler(r):
280
+ return "ran"
281
+
282
+ w = tg_langchain.atool_call_wrapper(POLICY, **KW)
283
+ self.assertEqual(asyncio.run(w(self.req("pay.out", {"url": "good.example"}), handler)), "ran")
284
+ with self.assertRaises(GuardHold):
285
+ asyncio.run(w(self.req("pay.out", {"url": "crash.example"}), handler))
286
+
287
+
288
+ class OpenAIAgents(unittest.TestCase):
289
+ def data(self, name, args, call_id="c1", approved=None):
290
+ ctx = SimpleNamespace(tool_name=name, tool_call_id=call_id, tool_arguments=json.dumps(args), is_tool_approved=lambda t, c: (approved or {}).get((t, c)))
291
+ return SimpleNamespace(context=ctx, agent=None)
292
+
293
+ def run_g(self, g, d):
294
+ return asyncio.run(g(d))
295
+
296
+ def test_guardrail(self):
297
+ g = tg_openai.tool_input_guardrail_function(POLICY, tools=["pay.*"], output_factory=tg_openai._Output, **KW)
298
+ self.assertEqual(self.run_g(g, self.data("pay.out", {"url": "good.example"})).behavior, {"type": "allow"})
299
+ self.assertEqual(self.run_g(g, self.data("pay.out", {"url": "bad.example"})).behavior, {"type": "raise_exception"})
300
+ r = self.run_g(g, self.data("pay.out", {"url": "unknown.example"}))
301
+ self.assertEqual(r.behavior["type"], "reject_content")
302
+ self.assertIn("A person must approve it", r.behavior["message"])
303
+ self.assertEqual(r.output_info["decision"]["decision"], "hold")
304
+ u = self.run_g(g, self.data("search", {"q": 1}))
305
+ self.assertEqual((u.behavior, u.output_info), ({"type": "allow"}, {"covered": False}))
306
+ f = self.run_g(g, self.data("pay.out", {"url": "crash.example"}))
307
+ self.assertEqual(f.behavior["type"], "reject_content")
308
+ raise_on_hold = tg_openai.tool_input_guardrail_function(POLICY, on_hold="raise", output_factory=tg_openai._Output, **KW)
309
+ self.assertEqual(self.run_g(raise_on_hold, self.data("pay.out", {"url": "unknown.example"})).behavior, {"type": "raise_exception"})
310
+ with self.assertRaises(ValueError):
311
+ tg_openai.tool_input_guardrail_function(POLICY, on_hold="allow")
312
+
313
+ def test_approved_hold_runs_but_approved_deny_does_not(self):
314
+ g = tg_openai.tool_input_guardrail_function(POLICY, output_factory=tg_openai._Output, **KW)
315
+ ok = {("pay.out", "c9"): True}
316
+ r = self.run_g(g, self.data("pay.out", {"url": "unknown.example"}, "c9", ok))
317
+ self.assertEqual(r.behavior, {"type": "allow"})
318
+ self.assertTrue(r.output_info["approved_by_person"])
319
+ self.assertEqual(self.run_g(g, self.data("pay.out", {"url": "unknown.example"}, "c8", ok)).behavior["type"], "reject_content")
320
+ self.assertEqual(self.run_g(g, self.data("pay.out", {"url": "bad.example"}, "c9", ok)).behavior, {"type": "raise_exception"})
321
+
322
+ def test_needs_approval(self):
323
+ na = tg_openai.needs_approval(POLICY, "pay.out", **KW)
324
+ self.assertTrue(asyncio.run(na(None, {"url": "unknown.example"}, "c1")))
325
+ self.assertFalse(asyncio.run(na(None, {"url": "good.example"}, "c2")))
326
+ self.assertFalse(asyncio.run(na(None, {"url": "bad.example"}, "c3")))
327
+ self.assertTrue(asyncio.run(na(None, {"url": "crash.example"}, "c4")))
328
+ # A tool the policy does not cover: decide() returns None, so no approval is needed and nothing is decided.
329
+ seen = []
330
+ uncovered = tg_openai.needs_approval(POLICY, "uncovered.tool", on_decision=seen.append, **KW)
331
+ self.assertFalse(asyncio.run(uncovered(None, {"url": "bad.example"}, "c5")))
332
+ self.assertEqual(seen, [])
333
+
334
+ def test_duck_output_matches_sdk_names(self):
335
+ self.assertEqual(tg_openai._Output.allow().behavior, {"type": "allow"})
336
+ self.assertEqual(tg_openai._Output.reject_content("m").behavior, {"type": "reject_content", "message": "m"})
337
+ self.assertEqual(tg_openai._Output.raise_exception({"x": 1}).output_info, {"x": 1})
338
+
339
+
340
+ if __name__ == "__main__":
341
+ unittest.main()
@@ -0,0 +1,96 @@
1
+ """Every vector and bundle in tests/vectors/ of trooth-cli, through this package."""
2
+
3
+ import json
4
+ import pathlib
5
+ import unittest
6
+
7
+ from trooth_verify import BundleError, canonicalize, is_canonical, statement_id, verify_bundle, verify_statement
8
+
9
+ ROOT = pathlib.Path(__file__).resolve().parents[3]
10
+ VEC = ROOT / "tests" / "vectors"
11
+
12
+
13
+ class Vectors(unittest.TestCase):
14
+ def test_vectors(self):
15
+ doc = json.loads((VEC / "vectors.json").read_text("utf-8"))
16
+ mapping = (VEC / doc["mapping_file"]).read_bytes()
17
+ other = (VEC / doc["other_mapping_file"]).read_bytes()
18
+ self.assertGreaterEqual(len(doc["vectors"]), 27)
19
+ for v in doc["vectors"]:
20
+ with self.subTest(v["name"]):
21
+ mb = mapping if v["mapping"] is True else other if v["mapping"] == "other" else None
22
+ r = verify_statement(v["statement"], v["keys"], mb, v["manifest"], v["domain"])
23
+ self.assertEqual(r["verdict"], v["expect"]["verdict"], v["note"])
24
+ self.assertEqual(r["signature"], v["expect"]["signature"], v["note"])
25
+
26
+ def test_bundles(self):
27
+ for b in json.loads((VEC / "bundles.json").read_text("utf-8"))["bundles"]:
28
+ with self.subTest(b["name"]):
29
+ if "error" in b["expect"]:
30
+ with self.assertRaises(BundleError):
31
+ verify_bundle(b["bundle"], b.get("domain"))
32
+ continue
33
+ r = verify_bundle(b["bundle"], b.get("domain"))
34
+ self.assertEqual(r["verdict"], b["expect"]["verdict"], b["note"])
35
+ self.assertEqual(r["signature"], b["expect"]["signature"], b["note"])
36
+
37
+ def test_real_trooth_bundle(self):
38
+ b = json.loads((ROOT / "tests" / "fixtures" / "bundles" / "trooth.co-2026-10-06.bundle.json").read_text("utf-8"))
39
+ r = verify_bundle(b)
40
+ self.assertEqual(r["verdict"], "checked")
41
+ self.assertEqual(r["statement_id"], statement_id(b["statement"]["payload"]))
42
+
43
+ def test_canonical_v3_payload_is_reproduced(self):
44
+ doc = json.loads((VEC / "vectors.json").read_text("utf-8"))
45
+ p = next(v for v in doc["vectors"] if v["name"] == "valid-v3")["statement"]["payload"]
46
+ self.assertTrue(is_canonical(p))
47
+ self.assertEqual(canonicalize(json.loads(p)), p)
48
+ self.assertEqual(canonicalize({"\U0001F600": 1, "דּ": 2, "b": 0}), '{"b":0,"\U0001F600":1,"דּ":2}')
49
+
50
+
51
+ class Log(unittest.TestCase):
52
+ def test_log_cases(self):
53
+ from trooth_verify import verify_statement
54
+ doc = json.loads((VEC / "log.json").read_text("utf-8"))
55
+ v = json.loads((VEC / "vectors.json").read_text("utf-8"))
56
+ mapping = (VEC / v["mapping_file"]).read_bytes()
57
+ for c in doc["cases"]:
58
+ with self.subTest(c["name"]):
59
+ r = verify_statement(c["statement"], c["keys"], mapping, c["manifest"], c["domain"], c["log"])
60
+ self.assertEqual(r["verdict"], c["expect"]["verdict"], c["note"])
61
+ self.assertEqual(r["log"]["status"] if r.get("log") else None, c["expect"]["log"], c["note"])
62
+
63
+ def test_consistency(self):
64
+ import base64
65
+ from trooth_verify import verify_consistency
66
+ doc = json.loads((VEC / "log.json").read_text("utf-8"))
67
+ for c in doc["consistency"]:
68
+ with self.subTest(c["name"]):
69
+ d = lambda s: base64.b64decode(s)
70
+ self.assertEqual(verify_consistency(c["first"], c["second"], d(c["first_root"]), d(c["second_root"]), [d(p) for p in c["proof"]]), c["expect"])
71
+
72
+ def test_checkpoints(self):
73
+ from trooth_verify import open_checkpoint
74
+ doc = json.loads((VEC / "log.json").read_text("utf-8"))
75
+ self.assertEqual(open_checkpoint(doc["checkpoints"]["5"], doc["vkey"])["size"], 5)
76
+ with self.assertRaises(ValueError):
77
+ open_checkpoint(doc["checkpoints"]["5"], doc["impostor_vkey"])
78
+
79
+
80
+ class Models(unittest.TestCase):
81
+ def test_models_load_vectors(self):
82
+ try:
83
+ from trooth_verify import models
84
+ except ImportError:
85
+ self.skipTest("pydantic is not installed (pip install trooth-verify[models])")
86
+ doc = json.loads((VEC / "vectors.json").read_text("utf-8"))
87
+ for name, cls in (("valid-v2", models.WitnessPayloadV2), ("valid-v3", models.WitnessPayloadV3), ("valid-v1", models.WitnessPayloadV1)):
88
+ v = next(x for x in doc["vectors"] if x["name"] == name)
89
+ cls.model_validate_json(v["statement"]["payload"])
90
+ models.WitnessStatement.model_validate(v["statement"])
91
+ b = json.loads((VEC / "bundles.json").read_text("utf-8"))["bundles"][0]["bundle"]
92
+ models.VerificationBundle.model_validate(b)
93
+
94
+
95
+ if __name__ == "__main__":
96
+ unittest.main()
@@ -0,0 +1,53 @@
1
+ """trooth_guard: the Trooth guard for Python agent frameworks.
2
+
3
+ from trooth_guard import decide
4
+ d = decide("policy.yaml", "stripe.create_payout", host="api.example.com")
5
+ d["decision"] # "allow", "hold" or "deny"
6
+
7
+ One decision implementation: this package runs `trooth guard decide --json`
8
+ (the npm package `trooth`) in a subprocess and parses its Decision. It needs
9
+ no Python package. Adapters, each importing nothing from its framework:
10
+ trooth_guard.crewai, trooth_guard.langgraph, trooth_guard.langchain and
11
+ trooth_guard.openai_agents. Licensed under the Apache License 2.0.
12
+
13
+ The guard answers allow, hold or deny about an action under the caller's
14
+ policy. It never labels a company.
15
+ """
16
+
17
+ from .core import (
18
+ EXIT_ALLOW,
19
+ EXIT_DENY,
20
+ EXIT_HOLD,
21
+ TROOTH_VERSION,
22
+ GuardDeny,
23
+ GuardError,
24
+ GuardHold,
25
+ decide,
26
+ decide_tool_call,
27
+ describe,
28
+ covered,
29
+ error_for,
30
+ fail_closed,
31
+ is_approval,
32
+ reason_codes,
33
+ trooth_command,
34
+ )
35
+
36
+ __all__ = [
37
+ "EXIT_ALLOW",
38
+ "EXIT_DENY",
39
+ "EXIT_HOLD",
40
+ "TROOTH_VERSION",
41
+ "GuardDeny",
42
+ "GuardError",
43
+ "GuardHold",
44
+ "decide",
45
+ "decide_tool_call",
46
+ "describe",
47
+ "covered",
48
+ "error_for",
49
+ "fail_closed",
50
+ "is_approval",
51
+ "reason_codes",
52
+ "trooth_command",
53
+ ]