@@ -90,7 +90,61 @@ def _defaults_only_template_payload() -> dict[str, object]:
9090 }
9191
9292
93- def _raw_control_payload (pattern : str = "raw" ) -> dict [str , object ]:
93+ def _case_sensitive_template_payload (
94+ * ,
95+ values : list [str ] | None = None ,
96+ case_sensitive : bool | None = None ,
97+ action : str = "deny" ,
98+ ) -> dict [str , object ]:
99+ template_values : dict [str , object ] = {}
100+ if values is not None :
101+ template_values ["values" ] = values
102+ if case_sensitive is not None :
103+ template_values ["case_sensitive" ] = case_sensitive
104+
105+ return {
106+ "template" : {
107+ "description" : "Case sensitivity template" ,
108+ "parameters" : {
109+ "values" : {
110+ "type" : "string_list" ,
111+ "label" : "Values" ,
112+ "required" : False ,
113+ "default" : ["HELLO" ],
114+ },
115+ "case_sensitive" : {
116+ "type" : "boolean" ,
117+ "label" : "Case Sensitive" ,
118+ "required" : False ,
119+ "default" : True ,
120+ },
121+ },
122+ "definition_template" : {
123+ "description" : "Case sensitivity control" ,
124+ "execution" : "server" ,
125+ "scope" : {
126+ "step_names" : ["templated-step" ],
127+ "stages" : ["pre" ],
128+ },
129+ "condition" : {
130+ "selector" : {"path" : "input" },
131+ "evaluator" : {
132+ "name" : "list" ,
133+ "config" : {
134+ "values" : {"$param" : "values" },
135+ "match_mode" : "exact" ,
136+ "case_sensitive" : {"$param" : "case_sensitive" },
137+ },
138+ },
139+ },
140+ "action" : {"decision" : action },
141+ },
142+ },
143+ "template_values" : template_values ,
144+ }
145+
146+
147+ def _raw_control_payload (pattern : str = "raw" , * , action : str = "deny" ) -> dict [str , object ]:
94148 return {
95149 "description" : "Raw control" ,
96150 "enabled" : True ,
@@ -103,7 +157,7 @@ def _raw_control_payload(pattern: str = "raw") -> dict[str, object]:
103157 "config" : {"pattern" : pattern },
104158 },
105159 },
106- "action" : {"decision" : "deny" },
160+ "action" : {"decision" : action },
107161 }
108162
109163
@@ -112,13 +166,18 @@ def _create_template_control(client: TestClient) -> int:
112166 return control_id
113167
114168
115- def _create_template_control_with_name (client : TestClient ) -> tuple [int , str ]:
116- control_name = f"template-control-{ uuid .uuid4 ()} "
169+ def _create_template_control_with_name (
170+ client : TestClient ,
171+ payload : dict [str , object ] | None = None ,
172+ * ,
173+ name_prefix : str = "template-control" ,
174+ ) -> tuple [int , str ]:
175+ control_name = f"{ name_prefix } -{ uuid .uuid4 ()} "
117176 response = client .put (
118177 "/api/v1/controls" ,
119178 json = {
120179 "name" : control_name ,
121- "data" : _template_payload (),
180+ "data" : payload or _template_payload (),
122181 },
123182 )
124183 assert response .status_code == 200 , response .text
@@ -130,23 +189,29 @@ def _assign_control_to_agent(
130189 control_id : int ,
131190 * ,
132191 agent_name : str | None = None ,
192+ via_policy : bool = True ,
133193) -> str :
134- policy_response = client .put ("/api/v1/policies" , json = {"name" : f"policy-{ uuid .uuid4 ()} " })
135- assert policy_response .status_code == 200 , policy_response .text
136- policy_id = policy_response .json ()["policy_id" ]
137-
138- add_control_response = client .post (f"/api/v1/policies/{ policy_id } /controls/{ control_id } " )
139- assert add_control_response .status_code == 200 , add_control_response .text
140-
141194 effective_agent_name = agent_name or f"template-agent-{ uuid .uuid4 ().hex [:12 ]} "
142195 init_response = client .post (
143196 "/api/v1/agents/initAgent" ,
144197 json = {"agent" : {"agent_name" : effective_agent_name }, "steps" : []},
145198 )
146199 assert init_response .status_code == 200 , init_response .text
147200
148- assign_response = client .post (f"/api/v1/agents/{ effective_agent_name } /policy/{ policy_id } " )
149- assert assign_response .status_code == 200 , assign_response .text
201+ if via_policy :
202+ policy_response = client .put ("/api/v1/policies" , json = {"name" : f"policy-{ uuid .uuid4 ()} " })
203+ assert policy_response .status_code == 200 , policy_response .text
204+ policy_id = policy_response .json ()["policy_id" ]
205+
206+ add_control_response = client .post (f"/api/v1/policies/{ policy_id } /controls/{ control_id } " )
207+ assert add_control_response .status_code == 200 , add_control_response .text
208+
209+ assign_response = client .post (f"/api/v1/agents/{ effective_agent_name } /policy/{ policy_id } " )
210+ assert assign_response .status_code == 200 , assign_response .text
211+ else :
212+ assign_response = client .post (f"/api/v1/agents/{ effective_agent_name } /controls/{ control_id } " )
213+ assert assign_response .status_code == 200 , assign_response .text
214+
150215 return effective_agent_name
151216
152217
@@ -162,6 +227,23 @@ def _create_raw_control(client: TestClient) -> int:
162227 return response .json ()["control_id" ]
163228
164229
230+ def _evaluate_step (
231+ client : TestClient ,
232+ agent_name : str ,
233+ * ,
234+ step_name : str ,
235+ input_value : str ,
236+ step_type : str = "llm" ,
237+ stage : str = "pre" ,
238+ ):
239+ request = EvaluationRequest (
240+ agent_name = agent_name ,
241+ step = Step (type = step_type , name = step_name , input = input_value , output = None ),
242+ stage = stage ,
243+ )
244+ return client .post ("/api/v1/evaluation" , json = request .model_dump (mode = "json" ))
245+
246+
165247def test_render_control_template_preview_returns_rendered_control (client : TestClient ) -> None :
166248 response = client .post ("/api/v1/control-templates/render" , json = _template_payload ())
167249
@@ -215,27 +297,222 @@ def test_template_backed_control_evaluates_after_policy_attachment(client: TestC
215297 control_id , control_name = _create_template_control_with_name (client )
216298 agent_name = _assign_control_to_agent (client , control_id )
217299
218- safe_request = EvaluationRequest (
219- agent_name = agent_name ,
220- step = Step (type = "llm" , name = "other-step" , input = "hello" , output = None ),
221- stage = "pre" ,
300+ safe_response = _evaluate_step (
301+ client ,
302+ agent_name ,
303+ step_name = "other-step" ,
304+ input_value = "hello" ,
222305 )
223- safe_response = client .post ("/api/v1/evaluation" , json = safe_request .model_dump (mode = "json" ))
224306 assert safe_response .status_code == 200 , safe_response .text
225307 assert safe_response .json ()["is_safe" ] is True
226308
227- deny_request = EvaluationRequest (
228- agent_name = agent_name ,
229- step = Step (type = "llm" , name = "templated-step" , input = "hello" , output = None ),
230- stage = "pre" ,
309+ deny_response = _evaluate_step (
310+ client ,
311+ agent_name ,
312+ step_name = "templated-step" ,
313+ input_value = "hello" ,
231314 )
232- deny_response = client .post ("/api/v1/evaluation" , json = deny_request .model_dump (mode = "json" ))
233315 assert deny_response .status_code == 200 , deny_response .text
234316 body = deny_response .json ()
235317 assert body ["is_safe" ] is False
236318 assert body ["matches" ][0 ]["control_name" ] == control_name
237319
238320
321+ def test_template_backed_control_can_be_disabled_and_reenabled_in_evaluation (
322+ client : TestClient ,
323+ ) -> None :
324+ control_id , control_name = _create_template_control_with_name (client )
325+ agent_name = _assign_control_to_agent (client , control_id )
326+
327+ initial_response = _evaluate_step (
328+ client ,
329+ agent_name ,
330+ step_name = "templated-step" ,
331+ input_value = "hello" ,
332+ )
333+ assert initial_response .status_code == 200 , initial_response .text
334+ assert initial_response .json ()["is_safe" ] is False
335+
336+ disable_response = client .patch (f"/api/v1/controls/{ control_id } " , json = {"enabled" : False })
337+ assert disable_response .status_code == 200 , disable_response .text
338+
339+ disabled_eval = _evaluate_step (
340+ client ,
341+ agent_name ,
342+ step_name = "templated-step" ,
343+ input_value = "hello" ,
344+ )
345+ assert disabled_eval .status_code == 200 , disabled_eval .text
346+ assert disabled_eval .json ()["is_safe" ] is True
347+
348+ enable_response = client .patch (f"/api/v1/controls/{ control_id } " , json = {"enabled" : True })
349+ assert enable_response .status_code == 200 , enable_response .text
350+
351+ reenabled_eval = _evaluate_step (
352+ client ,
353+ agent_name ,
354+ step_name = "templated-step" ,
355+ input_value = "hello" ,
356+ )
357+ assert reenabled_eval .status_code == 200 , reenabled_eval .text
358+ body = reenabled_eval .json ()
359+ assert body ["is_safe" ] is False
360+ assert body ["matches" ][0 ]["control_name" ] == control_name
361+
362+
363+ def test_template_backed_control_update_changes_scope_behavior (client : TestClient ) -> None :
364+ control_id , control_name = _create_template_control_with_name (client )
365+ agent_name = _assign_control_to_agent (client , control_id )
366+
367+ initial_eval = _evaluate_step (
368+ client ,
369+ agent_name ,
370+ step_name = "templated-step" ,
371+ input_value = "hello" ,
372+ )
373+ assert initial_eval .status_code == 200 , initial_eval .text
374+ assert initial_eval .json ()["is_safe" ] is False
375+
376+ updated_payload = _template_payload ()
377+ updated_payload ["template_values" ] = {
378+ "pattern" : "hello" ,
379+ "step_name" : "updated-step" ,
380+ }
381+ update_response = client .put (
382+ f"/api/v1/controls/{ control_id } /data" ,
383+ json = {"data" : updated_payload },
384+ )
385+ assert update_response .status_code == 200 , update_response .text
386+
387+ old_scope_eval = _evaluate_step (
388+ client ,
389+ agent_name ,
390+ step_name = "templated-step" ,
391+ input_value = "hello" ,
392+ )
393+ assert old_scope_eval .status_code == 200 , old_scope_eval .text
394+ assert old_scope_eval .json ()["is_safe" ] is True
395+
396+ updated_scope_eval = _evaluate_step (
397+ client ,
398+ agent_name ,
399+ step_name = "updated-step" ,
400+ input_value = "hello" ,
401+ )
402+ assert updated_scope_eval .status_code == 200 , updated_scope_eval .text
403+ body = updated_scope_eval .json ()
404+ assert body ["is_safe" ] is False
405+ assert body ["matches" ][0 ]["control_name" ] == control_name
406+
407+
408+ def test_template_backed_control_supports_direct_agent_attachment (client : TestClient ) -> None :
409+ control_id , control_name = _create_template_control_with_name (client )
410+ agent_name = _assign_control_to_agent (client , control_id , via_policy = False )
411+
412+ response = _evaluate_step (
413+ client ,
414+ agent_name ,
415+ step_name = "templated-step" ,
416+ input_value = "hello" ,
417+ )
418+
419+ assert response .status_code == 200 , response .text
420+ body = response .json ()
421+ assert body ["is_safe" ] is False
422+ assert body ["matches" ][0 ]["control_name" ] == control_name
423+
424+
425+ def test_template_backed_control_preserves_falsey_values_and_uses_them_in_behavior (
426+ client : TestClient ,
427+ ) -> None :
428+ payload = _case_sensitive_template_payload (values = [], case_sensitive = False )
429+ control_id , control_name = _create_template_control_with_name (
430+ client ,
431+ payload ,
432+ name_prefix = "falsey-template-control" ,
433+ )
434+ agent_name = _assign_control_to_agent (client , control_id )
435+
436+ get_response = client .get (f"/api/v1/controls/{ control_id } /data" )
437+ assert get_response .status_code == 200 , get_response .text
438+ data = get_response .json ()["data" ]
439+ assert data ["template_values" ] == {
440+ "values" : [],
441+ "case_sensitive" : False ,
442+ }
443+
444+ non_applicable_eval = _evaluate_step (
445+ client ,
446+ agent_name ,
447+ step_name = "templated-step" ,
448+ input_value = "hello" ,
449+ )
450+ assert non_applicable_eval .status_code == 200 , non_applicable_eval .text
451+ assert non_applicable_eval .json ()["is_safe" ] is True
452+
453+ updated_payload = _case_sensitive_template_payload (
454+ values = ["HELLO" ],
455+ case_sensitive = False ,
456+ )
457+ update_response = client .put (
458+ f"/api/v1/controls/{ control_id } /data" ,
459+ json = {"data" : updated_payload },
460+ )
461+ assert update_response .status_code == 200 , update_response .text
462+
463+ deny_eval = _evaluate_step (
464+ client ,
465+ agent_name ,
466+ step_name = "templated-step" ,
467+ input_value = "hello" ,
468+ )
469+ assert deny_eval .status_code == 200 , deny_eval .text
470+ body = deny_eval .json ()
471+ assert body ["is_safe" ] is False
472+ assert body ["matches" ][0 ]["control_name" ] == control_name
473+
474+
475+ def test_mixed_raw_and_template_backed_controls_obey_deny_precedence (
476+ client : TestClient ,
477+ ) -> None :
478+ template_control_id , template_control_name = _create_template_control_with_name (client )
479+ agent_name = _assign_control_to_agent (client , template_control_id )
480+
481+ policy_response = client .get (f"/api/v1/agents/{ agent_name } /policy" )
482+ assert policy_response .status_code == 200 , policy_response .text
483+ policy_id = policy_response .json ()["policy_id" ]
484+
485+ raw_warn_name = f"raw-warn-{ uuid .uuid4 ()} "
486+ raw_warn_response = client .put (
487+ "/api/v1/controls" ,
488+ json = {
489+ "name" : raw_warn_name ,
490+ "data" : _raw_control_payload ("hello" , action = "warn" ),
491+ },
492+ )
493+ assert raw_warn_response .status_code == 200 , raw_warn_response .text
494+ raw_warn_control_id = raw_warn_response .json ()["control_id" ]
495+
496+ add_control_response = client .post (f"/api/v1/policies/{ policy_id } /controls/{ raw_warn_control_id } " )
497+ assert add_control_response .status_code == 200 , add_control_response .text
498+
499+ response = _evaluate_step (
500+ client ,
501+ agent_name ,
502+ step_name = "templated-step" ,
503+ input_value = "hello" ,
504+ )
505+
506+ assert response .status_code == 200 , response .text
507+ body = response .json ()
508+ assert body ["is_safe" ] is False
509+ assert len (body ["matches" ]) == 2
510+ names = {match ["control_name" ] for match in body ["matches" ]}
511+ actions = {match ["action" ] for match in body ["matches" ]}
512+ assert names == {template_control_name , raw_warn_name }
513+ assert actions == {"deny" , "warn" }
514+
515+
239516def test_raw_control_can_be_replaced_with_template_backed_control (client : TestClient ) -> None :
240517 control_id = _create_raw_control (client )
241518
0 commit comments