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.
- trooth_verify-0.4.0/PKG-INFO +38 -0
- trooth_verify-0.4.0/README.md +24 -0
- trooth_verify-0.4.0/pyproject.toml +23 -0
- trooth_verify-0.4.0/setup.cfg +4 -0
- trooth_verify-0.4.0/tests/test_guard.py +341 -0
- trooth_verify-0.4.0/tests/test_vectors.py +96 -0
- trooth_verify-0.4.0/trooth_guard/__init__.py +53 -0
- trooth_verify-0.4.0/trooth_guard/core.py +230 -0
- trooth_verify-0.4.0/trooth_guard/crewai.py +118 -0
- trooth_verify-0.4.0/trooth_guard/langchain.py +149 -0
- trooth_verify-0.4.0/trooth_guard/langgraph.py +103 -0
- trooth_verify-0.4.0/trooth_guard/openai_agents.py +157 -0
- trooth_verify-0.4.0/trooth_verify/__init__.py +39 -0
- trooth_verify-0.4.0/trooth_verify/jcs.py +88 -0
- trooth_verify-0.4.0/trooth_verify/models.py +1672 -0
- trooth_verify-0.4.0/trooth_verify/tlog.py +216 -0
- trooth_verify-0.4.0/trooth_verify/verify.py +412 -0
- trooth_verify-0.4.0/trooth_verify.egg-info/PKG-INFO +38 -0
- trooth_verify-0.4.0/trooth_verify.egg-info/SOURCES.txt +20 -0
- trooth_verify-0.4.0/trooth_verify.egg-info/dependency_links.txt +1 -0
- trooth_verify-0.4.0/trooth_verify.egg-info/requires.txt +4 -0
- trooth_verify-0.4.0/trooth_verify.egg-info/top_level.txt +2 -0
|
@@ -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,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
|
+
]
|