Skip to content

Commit a6aa175

Browse files
RoyLinRoyLin
authored andcommitted
test(sdk/py): comprehensive tests → 91% region / 97% line coverage of the pyo3 binding
Expanded to 15 tests mirroring the TS suite: all 6 event builders (+ identity/provider envelope), create() success + ValueError, evaluate None, all severity arms, all action arms + non-block, L2 tier (unreachable URL → tier=Llm), L3 tier (mock agent → tier=Agent), repr coverage. Region 91.30% / line 97.30% of src/lib.rs — the only 3 missed lines are 2 #[pymethods] codegen attribute lines + the unreachable escalate arm. Reproduce with ./coverage.sh.
1 parent 8cea632 commit a6aa175

2 files changed

Lines changed: 180 additions & 30 deletions

File tree

sdk/python/coverage.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/usr/bin/env bash
2+
# Region/line coverage of the PyO3 binding (src/lib.rs), driven by the python test suite.
3+
# The only misses are the #[pymethods] macro attribute lines (generated codegen) and the unreachable
4+
# `escalate` arm; REGION coverage is the real measure. Needs the .venv from `maturin develop`.
5+
set -euo pipefail
6+
cd "$(dirname "$0")"
7+
eval "$(cargo llvm-cov show-env --export-prefix)"
8+
cargo llvm-cov clean --workspace
9+
. .venv/bin/activate
10+
maturin develop
11+
python -m unittest discover -s tests -t .
12+
cargo llvm-cov report --summary-only

sdk/python/tests/test_sentry.py

Lines changed: 168 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,35 @@
11
"""In-process tests for the native a3s-sentry Python SDK.
22
33
These are real judgments by the embedded Rust judge — no daemon, no external LLM/agent binary.
4+
Mirrors ``sdk/typescript/test/sdk.test.mjs`` so both bindings exercise the same paths.
45
"""
56

67
import os
8+
import stat
79
import tempfile
810
import unittest
911

1012
import a3s_sentry
11-
from a3s_sentry import Sentry, dns, egress, tool_exec
12-
13-
14-
# A custom Dns rule on top of the built-in defaults (cloud-metadata SSRF, etc.).
13+
from a3s_sentry import (
14+
Sentry,
15+
dns,
16+
egress,
17+
file_access,
18+
security_action,
19+
ssl_content,
20+
tool_exec,
21+
)
22+
23+
24+
# Custom rules covering every severity, on top of the built-in defaults. (Inline-object fields
25+
# are comma-separated; the regex backslash is doubled for the ACL and a raw string keeps it.)
1526
CFG = r"""
1627
fail_closed = false
1728
rules = [
18-
{ name = "block-evil-dns", on = "Dns", match = "evil\\.test",
19-
verdict = "block", severity = "high", reason = "custom dns rule" },
29+
{ name = "high-dns", on = "Dns", match = "high\\.test", verdict = "block", severity = "high", reason = "high rule" },
30+
{ name = "low-dns", on = "Dns", match = "low\\.test", verdict = "block", severity = "low", reason = "low rule" },
31+
{ name = "med-dns", on = "Dns", match = "med\\.test", verdict = "block", severity = "medium", reason = "med rule" },
32+
{ name = "crit-dns", on = "Dns", match = "crit\\.test", verdict = "block", severity = "critical", reason = "crit rule" },
2033
]
2134
"""
2235

@@ -25,6 +38,8 @@ class TestSentry(unittest.TestCase):
2538
def setUp(self):
2639
self.sentry = Sentry.create(CFG)
2740

41+
# --- create + built-in rules ---
42+
2843
def test_cloud_metadata_ssrf_blocks_with_deny_egress(self):
2944
# Built-in rule: egress to the cloud-metadata IP is an SSRF block.
3045
d = self.sentry.evaluate(egress(1, "169.254.169.254", 80))
@@ -34,13 +49,24 @@ def test_cloud_metadata_ssrf_blocks_with_deny_egress(self):
3449
self.assertEqual(d.action.kind, "DenyEgress")
3550
self.assertEqual(d.action.target, "169.254.169.254")
3651

37-
def test_custom_dns_rule_fires_at_rules_tier(self):
38-
d = self.sentry.evaluate(dns(1, "evil.test"))
52+
def test_sdk_authored_rule_fires_at_rules_tier(self):
53+
d = self.sentry.evaluate(dns(1, "high.test"))
3954
self.assertIsNotNone(d)
4055
self.assertEqual(d.verdict, "block")
4156
self.assertEqual(d.tier, "Rules")
42-
self.assertEqual(d.severity, "high")
43-
self.assertIn("custom dns rule", d.reason)
57+
self.assertIn("high rule", d.reason)
58+
59+
# --- all severity arms: info / low / medium / high / critical ---
60+
61+
def test_severity_arms(self):
62+
# benign allow → info
63+
self.assertEqual(self.sentry.evaluate(tool_exec(1, ["ls", "-la"])).severity, "info")
64+
self.assertEqual(self.sentry.evaluate(dns(1, "low.test")).severity, "low")
65+
self.assertEqual(self.sentry.evaluate(dns(1, "med.test")).severity, "medium")
66+
self.assertEqual(self.sentry.evaluate(dns(1, "high.test")).severity, "high")
67+
self.assertEqual(self.sentry.evaluate(dns(1, "crit.test")).severity, "critical")
68+
69+
# --- benign allow + unparseable handling ---
4470

4571
def test_benign_tool_exec_allows(self):
4672
d = self.sentry.evaluate(tool_exec(1, ["ls", "-la"]))
@@ -49,42 +75,154 @@ def test_benign_tool_exec_allows(self):
4975

5076
def test_unparseable_event_returns_none(self):
5177
self.assertIsNone(self.sentry.evaluate("not json"))
52-
53-
def test_evaluate_and_enforce_writes_deny_exec_file(self):
78+
self.assertIsNone(self.sentry.evaluate_and_enforce("still not json"))
79+
80+
# --- all six event builders, with and without identity/provider ---
81+
82+
def test_all_six_builders_produce_judgeable_events(self):
83+
for ev in (
84+
tool_exec(1, ["echo", "hi"]),
85+
egress(1, "8.8.8.8", 443),
86+
file_access(1, "/tmp/x", False),
87+
dns(1, "example.com"),
88+
ssl_content(1, "hello", True),
89+
security_action(1, "setuid-root", 0),
90+
):
91+
self.assertIsNotNone(self.sentry.evaluate(ev), "unparseable event: %s" % ev)
92+
93+
def test_builders_with_identity_and_provider(self):
94+
# Exercises the optional `agent`/`provider` envelope branches in `wrap`.
95+
for ev in (
96+
tool_exec(1, ["echo", "hi"], "agent-x", "openai"),
97+
egress(1, "8.8.8.8", 443, "agent-x", "openai"),
98+
file_access(1, "/tmp/x", False, "agent-x", "openai"),
99+
dns(1, "example.com", "agent-x", "openai"),
100+
ssl_content(1, "hello", True, "agent-x", "openai"),
101+
security_action(1, "setuid-root", 0, "agent-x", "openai"),
102+
):
103+
self.assertIn('"agent":"agent-x"', ev)
104+
self.assertIn('"provider":"openai"', ev)
105+
self.assertIsNotNone(self.sentry.evaluate(ev), "unparseable event: %s" % ev)
106+
107+
# --- all action arms via evaluate_and_enforce: DenyExec / DenyFile / DenyEgress / non-block ---
108+
109+
def test_evaluate_and_enforce_all_action_arms(self):
54110
with tempfile.TemporaryDirectory() as tmp:
55111
exec_sink = os.path.join(tmp, "exec.txt")
112+
file_sink = os.path.join(tmp, "file.txt")
113+
egress_sink = os.path.join(tmp, "egress.txt")
56114
cfg = (
57-
'deny { exec = "%s" }\n'
58-
'rules = [\n'
59-
' { name = "no-netcat", on = "ToolExec", match = "nc",\n'
60-
' verdict = "block", severity = "high", reason = "netcat",\n'
61-
' action = "deny-exec" },\n'
62-
"]\n" % exec_sink
63-
)
115+
"deny {\n"
116+
' exec = "%s"\n'
117+
' file = "%s"\n'
118+
' egress = "%s"\n'
119+
"}\n"
120+
"rules = [\n"
121+
' { name = "x-exec", on = "ToolExec", match = "danger", verdict = "block", severity = "high", reason = "x", action = "deny-exec" },\n'
122+
' { name = "x-file", on = "FileAccess", match = "/etc/shadow", verdict = "block", severity = "high", reason = "x", action = "deny-file" },\n'
123+
"]\n"
124+
) % (exec_sink, file_sink, egress_sink)
64125
sentry = Sentry.create(cfg)
65-
result = sentry.evaluate_and_enforce(tool_exec(1, ["/usr/bin/nc", "x", "4444"]))
66-
self.assertIsNotNone(result)
67-
decision, enforced = result
68-
self.assertEqual(decision.verdict, "block")
69-
self.assertEqual(decision.action.kind, "DenyExec")
70-
self.assertEqual(enforced, exec_sink)
126+
127+
# DenyExec — custom rule, writes the exec deny-file.
128+
ex = sentry.evaluate_and_enforce(tool_exec(1, ["/usr/bin/danger", "x"]))
129+
self.assertIsNotNone(ex)
130+
ex_decision, ex_enforced = ex
131+
self.assertEqual(ex_decision.verdict, "block")
132+
self.assertEqual(ex_decision.action.kind, "DenyExec")
133+
self.assertEqual(ex_enforced, exec_sink)
71134
with open(exec_sink) as f:
72-
contents = f.read()
73-
self.assertIn("/usr/bin/nc", contents)
135+
self.assertIn("danger", f.read())
136+
137+
# DenyFile — custom rule, writes the file deny-file.
138+
fa = sentry.evaluate_and_enforce(file_access(1, "/etc/shadow", True))
139+
self.assertIsNotNone(fa)
140+
fa_decision, fa_enforced = fa
141+
self.assertEqual(fa_decision.verdict, "block")
142+
self.assertEqual(fa_decision.action.kind, "DenyFile")
143+
self.assertEqual(fa_enforced, file_sink)
144+
145+
# DenyEgress — built-in cloud-metadata SSRF block, writes the egress deny-file.
146+
eg = sentry.evaluate_and_enforce(egress(1, "169.254.169.254", 80))
147+
self.assertIsNotNone(eg)
148+
eg_decision, eg_enforced = eg
149+
self.assertEqual(eg_decision.verdict, "block")
150+
self.assertEqual(eg_decision.action.kind, "DenyEgress")
151+
self.assertEqual(eg_enforced, egress_sink)
152+
153+
# non-block — benign event → no deny-file written.
154+
ok = sentry.evaluate_and_enforce(tool_exec(1, ["echo", "ok"]))
155+
self.assertIsNotNone(ok)
156+
ok_decision, ok_enforced = ok
157+
self.assertEqual(ok_decision.verdict, "allow")
158+
self.assertIsNone(ok_enforced)
159+
160+
# --- tier=Llm: escalating event with an unreachable LLM keeps tier=Llm / verdict=escalate ---
161+
162+
def test_llm_tier_escalation(self):
163+
# No mock server (an in-process HTTP server would deadlock the blocking FFI call). A closed
164+
# port refuses fast → L2 errors → escalate; with no L3 the unresolved escalation is resolved
165+
# per fail_closed (fail-open default → allow) but the tier is still recorded as Llm.
166+
# Exercises the Llm arm of `tier_str`.
167+
s = Sentry.create('llm { url = "http://127.0.0.1:1/v1" }')
168+
d = s.evaluate(file_access(1, "/home/u/.aws/credentials", False)) # built-in: escalate
169+
self.assertIsNotNone(d)
170+
self.assertEqual(d.tier, "Llm")
171+
172+
# --- tier=Agent: escalating event → mock L3 agent binary → tier=Agent ---
74173

75-
def test_repr_is_readable(self):
76-
d = self.sentry.evaluate(dns(1, "evil.test"))
174+
def test_agent_tier_via_mock_l3_binary(self):
175+
with tempfile.TemporaryDirectory() as tmp:
176+
bin_path = os.path.join(tmp, "mock-agent.sh")
177+
with open(bin_path, "w") as f:
178+
f.write('#!/bin/sh\necho \'{"verdict":"block","severity":"critical","reason":"mock L3"}\'\n')
179+
os.chmod(bin_path, 0o755)
180+
self.assertTrue(os.stat(bin_path).st_mode & stat.S_IXUSR)
181+
# agent{} but no llm{} → an L1 escalate goes straight to L3 (the mock).
182+
s = Sentry.create('agent { bin = "%s" }' % bin_path)
183+
d = s.evaluate(file_access(1, "/home/u/.aws/credentials", False)) # built-in: escalate
184+
self.assertIsNotNone(d)
185+
self.assertEqual(d.tier, "Agent")
186+
self.assertEqual(d.verdict, "block")
187+
self.assertIn("mock L3", d.reason)
188+
189+
# --- repr coverage: Decision repr with an action, and EnforceAction repr ---
190+
191+
def test_repr_includes_action(self):
192+
d = self.sentry.evaluate(egress(1, "169.254.169.254", 80))
77193
text = repr(d)
78194
self.assertIn("Decision(", text)
79195
self.assertIn('verdict="block"', text)
196+
# Decision repr embeds the EnforceAction repr (covers both __repr__ paths).
197+
self.assertIn("EnforceAction(", text)
198+
self.assertIn('kind="DenyEgress"', text)
199+
200+
def test_repr_without_action_is_none(self):
201+
d = self.sentry.evaluate(tool_exec(1, ["ls"]))
202+
text = repr(d)
203+
self.assertIn("Decision(", text)
204+
self.assertIn("action=None", text)
205+
206+
def test_enforce_action_repr_directly(self):
207+
d = self.sentry.evaluate(egress(1, "169.254.169.254", 80))
208+
action_text = repr(d.action)
209+
self.assertIn("EnforceAction(", action_text)
210+
self.assertIn('kind="DenyEgress"', action_text)
211+
self.assertIn('target="169.254.169.254"', action_text)
212+
213+
# --- create() error mapping ---
80214

81215
def test_bad_config_raises_value_error(self):
82216
with self.assertRaises(ValueError):
83-
Sentry.create('rules = [ { this is not valid hcl ')
217+
Sentry.create("not valid acl {{{")
218+
219+
# --- module surface ---
84220

85221
def test_module_exposes_builders(self):
86222
for name in ("tool_exec", "egress", "file_access", "dns", "ssl_content", "security_action"):
87223
self.assertTrue(hasattr(a3s_sentry, name), name)
224+
for name in ("Sentry", "Decision", "EnforceAction"):
225+
self.assertTrue(hasattr(a3s_sentry, name), name)
88226

89227

90228
if __name__ == "__main__":

0 commit comments

Comments
 (0)