11"""In-process tests for the native a3s-sentry Python SDK.
22
33These 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
67import os
8+ import stat
79import tempfile
810import unittest
911
1012import 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.)
1526CFG = r"""
1627fail_closed = false
1728rules = [
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\n echo \' {"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
90228if __name__ == "__main__" :
0 commit comments