Skip to content

Commit 19aa096

Browse files
committed
fix: tighten template control API contracts
1 parent 1dfb7f5 commit 19aa096

5 files changed

Lines changed: 182 additions & 2 deletions

File tree

‎models/src/agent_control_models/errors.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ class ErrorCode(StrEnum):
6868
CONTROL_NAME_CONFLICT = "CONTROL_NAME_CONFLICT"
6969
EVALUATOR_NAME_CONFLICT = "EVALUATOR_NAME_CONFLICT"
7070
CONTROL_IN_USE = "CONTROL_IN_USE"
71+
CONTROL_TEMPLATE_CONFLICT = "CONTROL_TEMPLATE_CONFLICT"
7172
EVALUATOR_IN_USE = "EVALUATOR_IN_USE"
7273
SCHEMA_INCOMPATIBLE = "SCHEMA_INCOMPATIBLE"
7374

@@ -370,6 +371,7 @@ def make_error_type(error_code: ErrorCode) -> str:
370371
ErrorCode.CONTROL_NAME_CONFLICT: "Control Name Already Exists",
371372
ErrorCode.EVALUATOR_NAME_CONFLICT: "Evaluator Name Conflict",
372373
ErrorCode.CONTROL_IN_USE: "Control In Use",
374+
ErrorCode.CONTROL_TEMPLATE_CONFLICT: "Control Template Conflict",
373375
ErrorCode.EVALUATOR_IN_USE: "Evaluator In Use",
374376
ErrorCode.SCHEMA_INCOMPATIBLE: "Schema Incompatible",
375377
# Validation errors

‎models/src/agent_control_models/server.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from enum import StrEnum
22
from typing import Annotated, Any
33

4-
from pydantic import BeforeValidator, Field, StringConstraints, TypeAdapter
4+
from pydantic import BeforeValidator, ConfigDict, Field, StringConstraints, TypeAdapter
55

66
from .agent import Agent, StepSchema
77
from .base import BaseModel
@@ -346,6 +346,8 @@ class ValidateControlDataResponse(BaseModel):
346346
class RenderControlTemplateRequest(BaseModel):
347347
"""Request to render a template-backed control without persisting it."""
348348

349+
model_config = ConfigDict(extra="forbid")
350+
349351
template: TemplateDefinition = Field(..., description="Template definition to render")
350352
template_values: dict[str, TemplateValue] = Field(
351353
default_factory=dict,

‎server/src/agent_control_server/endpoints/controls.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -541,7 +541,7 @@ async def set_control_data(
541541
else:
542542
if current_template_backed:
543543
raise ConflictError(
544-
error_code=ErrorCode.CONTROL_IN_USE,
544+
error_code=ErrorCode.CONTROL_TEMPLATE_CONFLICT,
545545
detail="Template-backed controls cannot be updated with raw control data in v1",
546546
resource="Control",
547547
resource_id=str(control_id),

‎server/src/agent_control_server/services/control_templates.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ def _parameter_default(
9595
parameter_definition: TemplateParameterDefinition,
9696
) -> TemplateValue | object:
9797
"""Return the explicit default for a parameter, if any."""
98+
# TemplateValue intentionally excludes None, so None continues to mean
99+
# "no default provided" for current v1 parameter types.
98100
default = getattr(parameter_definition, "default", None)
99101
return _TEMPLATE_VALUE_MISSING if default is None else cast(TemplateValue, default)
100102

‎server/tests/test_control_templates.py‎

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,23 @@ def _template_payload() -> dict[str, object]:
4646
}
4747

4848

49+
def _raw_control_payload(pattern: str = "raw") -> dict[str, object]:
50+
return {
51+
"description": "Raw control",
52+
"enabled": True,
53+
"execution": "server",
54+
"scope": {"step_types": ["llm"], "stages": ["pre"]},
55+
"condition": {
56+
"selector": {"path": "input"},
57+
"evaluator": {
58+
"name": "regex",
59+
"config": {"pattern": pattern},
60+
},
61+
},
62+
"action": {"decision": "deny"},
63+
}
64+
65+
4966
def _create_template_control(client: TestClient) -> int:
5067
response = client.put(
5168
"/api/v1/controls",
@@ -58,6 +75,18 @@ def _create_template_control(client: TestClient) -> int:
5875
return response.json()["control_id"]
5976

6077

78+
def _create_raw_control(client: TestClient) -> int:
79+
response = client.put(
80+
"/api/v1/controls",
81+
json={
82+
"name": f"raw-control-{uuid.uuid4()}",
83+
"data": _raw_control_payload(),
84+
},
85+
)
86+
assert response.status_code == 200, response.text
87+
return response.json()["control_id"]
88+
89+
6190
def test_render_control_template_preview_returns_rendered_control(client: TestClient) -> None:
6291
response = client.post("/api/v1/control-templates/render", json=_template_payload())
6392

@@ -84,6 +113,23 @@ def test_create_template_backed_control_persists_template_metadata(client: TestC
84113
assert data["condition"]["evaluator"]["config"]["pattern"] == "hello"
85114

86115

116+
def test_raw_control_can_be_replaced_with_template_backed_control(client: TestClient) -> None:
117+
control_id = _create_raw_control(client)
118+
119+
put_response = client.put(
120+
f"/api/v1/controls/{control_id}/data",
121+
json={"data": _template_payload()},
122+
)
123+
assert put_response.status_code == 200, put_response.text
124+
125+
get_response = client.get(f"/api/v1/controls/{control_id}/data")
126+
assert get_response.status_code == 200, get_response.text
127+
data = get_response.json()["data"]
128+
assert data["template"]["description"] == "Regex denial template"
129+
assert data["template_values"]["pattern"] == "hello"
130+
assert data["condition"]["evaluator"]["config"]["pattern"] == "hello"
131+
132+
87133
def test_template_update_preserves_enabled_value(client: TestClient) -> None:
88134
control_id = _create_template_control(client)
89135

@@ -157,6 +203,7 @@ def test_template_backed_control_rejects_raw_put_update(client: TestClient) -> N
157203
)
158204

159205
assert response.status_code == 409
206+
assert response.json()["error_code"] == "CONTROL_TEMPLATE_CONFLICT"
160207

161208

162209
def test_list_controls_includes_template_backed_flag_and_filter(client: TestClient) -> None:
@@ -167,3 +214,130 @@ def test_list_controls_includes_template_backed_flag_and_filter(client: TestClie
167214
controls = response.json()["controls"]
168215
template_control = next(control for control in controls if control["id"] == control_id)
169216
assert template_control["template_backed"] is True
217+
218+
219+
def test_list_controls_template_backed_false_returns_only_raw_controls(client: TestClient) -> None:
220+
template_control_id = _create_template_control(client)
221+
raw_control_id = _create_raw_control(client)
222+
223+
response = client.get("/api/v1/controls", params={"template_backed": False})
224+
assert response.status_code == 200, response.text
225+
control_ids = {control["id"] for control in response.json()["controls"]}
226+
assert raw_control_id in control_ids
227+
assert template_control_id not in control_ids
228+
229+
230+
def test_render_control_template_rejects_extra_request_fields(client: TestClient) -> None:
231+
payload = _template_payload()
232+
payload["execution"] = "server"
233+
234+
response = client.post("/api/v1/control-templates/render", json=payload)
235+
236+
assert response.status_code == 422
237+
assert response.json()["error_code"] == "VALIDATION_ERROR"
238+
239+
240+
def test_render_control_template_maps_invalid_regex_parameter(client: TestClient) -> None:
241+
payload = _template_payload()
242+
payload["template_values"] = {"pattern": "["}
243+
244+
response = client.post("/api/v1/control-templates/render", json=payload)
245+
246+
assert response.status_code == 422
247+
body = response.json()
248+
assert body["error_code"] == "TEMPLATE_PARAMETER_INVALID"
249+
assert any(
250+
err.get("field") == "template_values.pattern"
251+
and err.get("parameter") == "pattern"
252+
for err in body.get("errors", [])
253+
)
254+
255+
256+
def test_render_control_template_rejects_malformed_param_binding(client: TestClient) -> None:
257+
payload = _template_payload()
258+
payload["template"] = deepcopy(payload["template"])
259+
payload["template"]["definition_template"]["condition"]["evaluator"]["config"]["pattern"] = { # type: ignore[index]
260+
"$param": "pattern",
261+
"extra": True,
262+
}
263+
264+
response = client.post("/api/v1/control-templates/render", json=payload)
265+
266+
assert response.status_code == 422
267+
body = response.json()
268+
assert body["error_code"] == "TEMPLATE_RENDER_ERROR"
269+
assert any(err.get("code") == "invalid_param_binding" for err in body.get("errors", []))
270+
271+
272+
def test_render_control_template_rejects_non_string_param_reference(client: TestClient) -> None:
273+
payload = _template_payload()
274+
payload["template"] = deepcopy(payload["template"])
275+
payload["template"]["definition_template"]["condition"]["evaluator"]["config"]["pattern"] = { # type: ignore[index]
276+
"$param": 123,
277+
}
278+
279+
response = client.post("/api/v1/control-templates/render", json=payload)
280+
281+
assert response.status_code == 422
282+
body = response.json()
283+
assert body["error_code"] == "TEMPLATE_RENDER_ERROR"
284+
assert any(err.get("code") == "invalid_param_binding" for err in body.get("errors", []))
285+
286+
287+
def test_render_control_template_rejects_agent_scoped_evaluator(client: TestClient) -> None:
288+
payload = _template_payload()
289+
payload["template"] = deepcopy(payload["template"])
290+
payload["template"]["definition_template"]["condition"]["evaluator"]["name"] = "agent-x:custom" # type: ignore[index]
291+
292+
response = client.post("/api/v1/control-templates/render", json=payload)
293+
294+
assert response.status_code == 422
295+
body = response.json()
296+
assert body["error_code"] == "TEMPLATE_RENDER_ERROR"
297+
assert any(
298+
err.get("code") == "agent_scoped_evaluator_not_supported"
299+
for err in body.get("errors", [])
300+
)
301+
302+
303+
def test_render_control_template_rejects_legacy_flat_format(client: TestClient) -> None:
304+
payload = _template_payload()
305+
payload["template"] = deepcopy(payload["template"])
306+
payload["template"]["definition_template"] = { # type: ignore[index]
307+
"execution": "server",
308+
"scope": {"step_types": ["llm"], "stages": ["pre"]},
309+
"selector": {"path": "input"},
310+
"evaluator": {
311+
"name": "regex",
312+
"config": {"pattern": {"$param": "pattern"}},
313+
},
314+
"action": {"decision": "deny"},
315+
}
316+
317+
response = client.post("/api/v1/control-templates/render", json=payload)
318+
319+
assert response.status_code == 422
320+
body = response.json()
321+
assert body["error_code"] == "TEMPLATE_RENDER_ERROR"
322+
assert any(
323+
err.get("code") == "legacy_condition_format_not_supported"
324+
for err in body.get("errors", [])
325+
)
326+
327+
328+
def test_render_control_template_rejects_invalid_parameter_name_at_api_boundary(
329+
client: TestClient,
330+
) -> None:
331+
payload = _template_payload()
332+
payload["template"] = deepcopy(payload["template"])
333+
payload["template"]["parameters"] = { # type: ignore[index]
334+
"bad.name": {
335+
"type": "string",
336+
"label": "Bad Name",
337+
}
338+
}
339+
340+
response = client.post("/api/v1/control-templates/render", json=payload)
341+
342+
assert response.status_code == 422
343+
assert response.json()["error_code"] == "VALIDATION_ERROR"

0 commit comments

Comments
 (0)