55import uuid
66from copy import deepcopy
77
8+ from agent_control_models import EvaluationRequest , Step
89from fastapi .testclient import TestClient
910
1011
@@ -46,6 +47,49 @@ def _template_payload() -> dict[str, object]:
4647 }
4748
4849
50+ def _defaults_only_template_payload () -> dict [str , object ]:
51+ return {
52+ "template" : {
53+ "description" : "List evaluator template" ,
54+ "parameters" : {
55+ "values" : {
56+ "type" : "string_list" ,
57+ "label" : "Values" ,
58+ "default" : ["secret" , "blocked" ],
59+ },
60+ "logic" : {
61+ "type" : "enum" ,
62+ "label" : "Logic" ,
63+ "allowed_values" : ["any" , "all" ],
64+ "default" : "any" ,
65+ },
66+ "case_sensitive" : {
67+ "type" : "boolean" ,
68+ "label" : "Case Sensitive" ,
69+ "default" : False ,
70+ },
71+ },
72+ "definition_template" : {
73+ "description" : "Defaulted list control" ,
74+ "execution" : "server" ,
75+ "scope" : {"step_types" : ["llm" ], "stages" : ["pre" ]},
76+ "condition" : {
77+ "selector" : {"path" : "input" },
78+ "evaluator" : {
79+ "name" : "list" ,
80+ "config" : {
81+ "values" : {"$param" : "values" },
82+ "logic" : {"$param" : "logic" },
83+ "case_sensitive" : {"$param" : "case_sensitive" },
84+ },
85+ },
86+ },
87+ "action" : {"decision" : "deny" },
88+ },
89+ }
90+ }
91+
92+
4993def _raw_control_payload (pattern : str = "raw" ) -> dict [str , object ]:
5094 return {
5195 "description" : "Raw control" ,
@@ -64,15 +108,46 @@ def _raw_control_payload(pattern: str = "raw") -> dict[str, object]:
64108
65109
66110def _create_template_control (client : TestClient ) -> int :
111+ control_id , _ = _create_template_control_with_name (client )
112+ return control_id
113+
114+
115+ def _create_template_control_with_name (client : TestClient ) -> tuple [int , str ]:
116+ control_name = f"template-control-{ uuid .uuid4 ()} "
67117 response = client .put (
68118 "/api/v1/controls" ,
69119 json = {
70- "name" : f"template-control- { uuid . uuid4 () } " ,
120+ "name" : control_name ,
71121 "data" : _template_payload (),
72122 },
73123 )
74124 assert response .status_code == 200 , response .text
75- return response .json ()["control_id" ]
125+ return response .json ()["control_id" ], control_name
126+
127+
128+ def _assign_control_to_agent (
129+ client : TestClient ,
130+ control_id : int ,
131+ * ,
132+ agent_name : str | None = None ,
133+ ) -> 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+
141+ effective_agent_name = agent_name or f"template-agent-{ uuid .uuid4 ().hex [:12 ]} "
142+ init_response = client .post (
143+ "/api/v1/agents/initAgent" ,
144+ json = {"agent" : {"agent_name" : effective_agent_name }, "steps" : []},
145+ )
146+ assert init_response .status_code == 200 , init_response .text
147+
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
150+ return effective_agent_name
76151
77152
78153def _create_raw_control (client : TestClient ) -> int :
@@ -102,17 +177,65 @@ def test_render_control_template_preview_returns_rendered_control(client: TestCl
102177 assert control ["scope" ]["step_names" ] == ["templated-step" ]
103178
104179
180+ def test_render_control_template_preview_uses_defaults_when_values_omitted (
181+ client : TestClient ,
182+ ) -> None :
183+ response = client .post ("/api/v1/control-templates/render" , json = _defaults_only_template_payload ())
184+
185+ assert response .status_code == 200 , response .text
186+ control = response .json ()["control" ]
187+ assert control ["template_values" ] == {
188+ "values" : ["secret" , "blocked" ],
189+ "logic" : "any" ,
190+ "case_sensitive" : False ,
191+ }
192+ assert control ["condition" ]["evaluator" ]["name" ] == "list"
193+ assert control ["condition" ]["evaluator" ]["config" ] == {
194+ "values" : ["secret" , "blocked" ],
195+ "logic" : "any" ,
196+ "case_sensitive" : False ,
197+ }
198+
199+
105200def test_create_template_backed_control_persists_template_metadata (client : TestClient ) -> None :
106201 control_id = _create_template_control (client )
107202
108203 response = client .get (f"/api/v1/controls/{ control_id } /data" )
109204 assert response .status_code == 200 , response .text
110205 data = response .json ()["data" ]
111206 assert data ["template" ]["description" ] == "Regex denial template"
112- assert data ["template_values" ]["pattern" ] == "hello"
207+ assert data ["template_values" ] == {
208+ "pattern" : "hello" ,
209+ "step_name" : "templated-step" ,
210+ }
113211 assert data ["condition" ]["evaluator" ]["config" ]["pattern" ] == "hello"
114212
115213
214+ def test_template_backed_control_evaluates_after_policy_attachment (client : TestClient ) -> None :
215+ control_id , control_name = _create_template_control_with_name (client )
216+ agent_name = _assign_control_to_agent (client , control_id )
217+
218+ safe_request = EvaluationRequest (
219+ agent_name = agent_name ,
220+ step = Step (type = "llm" , name = "other-step" , input = "hello" , output = None ),
221+ stage = "pre" ,
222+ )
223+ safe_response = client .post ("/api/v1/evaluation" , json = safe_request .model_dump (mode = "json" ))
224+ assert safe_response .status_code == 200 , safe_response .text
225+ assert safe_response .json ()["is_safe" ] is True
226+
227+ deny_request = EvaluationRequest (
228+ agent_name = agent_name ,
229+ step = Step (type = "llm" , name = "templated-step" , input = "hello" , output = None ),
230+ stage = "pre" ,
231+ )
232+ deny_response = client .post ("/api/v1/evaluation" , json = deny_request .model_dump (mode = "json" ))
233+ assert deny_response .status_code == 200 , deny_response .text
234+ body = deny_response .json ()
235+ assert body ["is_safe" ] is False
236+ assert body ["matches" ][0 ]["control_name" ] == control_name
237+
238+
116239def test_raw_control_can_be_replaced_with_template_backed_control (client : TestClient ) -> None :
117240 control_id = _create_raw_control (client )
118241
@@ -178,6 +301,22 @@ def test_template_validate_maps_missing_parameter_error(client: TestClient) -> N
178301 )
179302
180303
304+ def test_render_control_template_rejects_unknown_template_value_key (client : TestClient ) -> None :
305+ payload = _template_payload ()
306+ payload ["template_values" ] = {"pattern" : "hello" , "unknown" : "value" }
307+
308+ response = client .post ("/api/v1/control-templates/render" , json = payload )
309+
310+ assert response .status_code == 422
311+ body = response .json ()
312+ assert body ["error_code" ] == "TEMPLATE_PARAMETER_INVALID"
313+ assert any (
314+ err .get ("field" ) == "template_values.unknown"
315+ and err .get ("code" ) == "unknown_parameter"
316+ for err in body .get ("errors" , [])
317+ )
318+
319+
181320def test_template_backed_control_rejects_raw_put_update (client : TestClient ) -> None :
182321 control_id = _create_template_control (client )
183322 raw_payload = deepcopy (
@@ -284,6 +423,27 @@ def test_render_control_template_rejects_non_string_param_reference(client: Test
284423 assert any (err .get ("code" ) == "invalid_param_binding" for err in body .get ("errors" , []))
285424
286425
426+ def test_render_control_template_rejects_unused_parameter (client : TestClient ) -> None :
427+ payload = _template_payload ()
428+ payload ["template" ] = deepcopy (payload ["template" ])
429+ payload ["template" ]["parameters" ]["unused" ] = { # type: ignore[index]
430+ "type" : "string" ,
431+ "label" : "Unused" ,
432+ "default" : "still-unused" ,
433+ }
434+
435+ response = client .post ("/api/v1/control-templates/render" , json = payload )
436+
437+ assert response .status_code == 422
438+ body = response .json ()
439+ assert body ["error_code" ] == "TEMPLATE_RENDER_ERROR"
440+ assert any (
441+ err .get ("field" ) == "template.parameters.unused"
442+ and err .get ("code" ) == "unused_template_parameter"
443+ for err in body .get ("errors" , [])
444+ )
445+
446+
287447def test_render_control_template_rejects_agent_scoped_evaluator (client : TestClient ) -> None :
288448 payload = _template_payload ()
289449 payload ["template" ] = deepcopy (payload ["template" ])
@@ -300,6 +460,25 @@ def test_render_control_template_rejects_agent_scoped_evaluator(client: TestClie
300460 )
301461
302462
463+ def test_render_control_template_rejects_forbidden_top_level_template_fields (
464+ client : TestClient ,
465+ ) -> None :
466+ for forbidden_field , value in (("enabled" , True ), ("name" , "templated-name" )):
467+ payload = _template_payload ()
468+ payload ["template" ] = deepcopy (payload ["template" ])
469+ payload ["template" ]["definition_template" ][forbidden_field ] = value # type: ignore[index]
470+
471+ response = client .post ("/api/v1/control-templates/render" , json = payload )
472+
473+ assert response .status_code == 422
474+ body = response .json ()
475+ assert body ["error_code" ] == "TEMPLATE_RENDER_ERROR"
476+ assert any (
477+ err .get ("field" ) == forbidden_field and err .get ("code" ) == "forbidden_template_field"
478+ for err in body .get ("errors" , [])
479+ )
480+
481+
303482def test_render_control_template_rejects_legacy_flat_format (client : TestClient ) -> None :
304483 payload = _template_payload ()
305484 payload ["template" ] = deepcopy (payload ["template" ])
@@ -341,3 +520,22 @@ def test_render_control_template_rejects_invalid_parameter_name_at_api_boundary(
341520
342521 assert response .status_code == 422
343522 assert response .json ()["error_code" ] == "VALIDATION_ERROR"
523+
524+
525+ def test_render_control_template_keeps_non_parameterized_errors_on_rendered_fields (
526+ client : TestClient ,
527+ ) -> None :
528+ payload = _template_payload ()
529+ payload ["template" ] = deepcopy (payload ["template" ])
530+ payload ["template" ]["definition_template" ]["action" ]["decision" ] = "block" # type: ignore[index]
531+
532+ response = client .post ("/api/v1/control-templates/render" , json = payload )
533+
534+ assert response .status_code == 422
535+ body = response .json ()
536+ assert body ["error_code" ] == "TEMPLATE_RENDER_ERROR"
537+ assert any (
538+ err .get ("field" ) == "action.decision"
539+ and err .get ("rendered_field" ) == "action.decision"
540+ for err in body .get ("errors" , [])
541+ )
0 commit comments