Skip to content

Commit b2e940d

Browse files
committed
fix: harden unrendered template controls
- Prevent rendered→unrendered downgrade: updating a rendered template control with incomplete values now forces a full render attempt, returning a clear error about missing parameters instead of silently stripping rendered fields - Deepen unrendered structural validation: validate_template_structure now walks definition_template to check $param bindings, reject undefined parameter references, detect unused parameters, and reject hardcoded agent-scoped evaluator names - Fix PATCH enabled=false on unrendered templates: detect unrendered state before attempting ControlDefinition.model_validate, treating disable as a no-op instead of raising CORRUPTED_DATA - Add 4 behavioral tests: rendered rejects incomplete update, PATCH disable no-op, unrendered rejects undefined $param / unused param / agent-scoped evaluator
1 parent 058cb06 commit b2e940d

3 files changed

Lines changed: 291 additions & 60 deletions

File tree

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

Lines changed: 76 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,24 @@ async def _materialize_control_input(
186186
enabled=enabled,
187187
)
188188

189-
# Incomplete values — store as unrendered template.
189+
# Incomplete values — only allowed for new controls or already-unrendered
190+
# templates. Updating a rendered control with incomplete values is
191+
# rejected to prevent silently stripping rendered fields.
192+
current_is_rendered = (
193+
current_payload is not None
194+
and isinstance(current_payload, dict)
195+
and current_payload.get("condition") is not None
196+
)
197+
if current_is_rendered:
198+
# Force a full render attempt so the caller gets a clear error
199+
# about which required parameters are missing.
200+
enabled = _enabled_from_stored_payload(current_payload)
201+
return await _render_and_validate_template_input(
202+
control_input,
203+
db=db,
204+
enabled=enabled,
205+
)
206+
190207
validate_template_structure(control_input.template)
191208
return UnrenderedTemplateControl(
192209
template=control_input.template,
@@ -1117,57 +1134,64 @@ async def patch_control(
11171134
],
11181135
)
11191136

1120-
if request.enabled and _is_unrendered_template(control.data):
1121-
raise APIValidationError(
1122-
error_code=ErrorCode.VALIDATION_ERROR,
1123-
detail=(
1124-
f"Cannot enable control '{control.name}': "
1125-
"unrendered template controls must be rendered first"
1126-
),
1127-
resource="Control",
1128-
hint=(
1129-
f"Provide complete parameter values via PUT /api/v1/controls/{control_id}/data "
1130-
"to render the template before enabling."
1131-
),
1132-
errors=[
1133-
ValidationErrorItem(
1134-
resource="Control",
1135-
field="enabled",
1136-
code="unrendered_template_cannot_enable",
1137-
message="Provide parameter values to render the template before enabling.",
1138-
)
1139-
],
1140-
)
1141-
1142-
try:
1143-
ctrl_def = ControlDefinition.model_validate(control.data)
1144-
if ctrl_def.enabled != request.enabled:
1145-
new_data = dict(control.data)
1146-
new_data["enabled"] = request.enabled
1147-
control.data = new_data
1148-
updated = True
1149-
current_enabled = request.enabled if updated else ctrl_def.enabled
1150-
except ValidationError:
1151-
_logger.error(
1152-
"Control '%s' (%s) has corrupted data in patch request",
1153-
control.name,
1154-
control_id,
1155-
exc_info=True,
1156-
)
1157-
raise APIValidationError(
1158-
error_code=ErrorCode.CORRUPTED_DATA,
1159-
detail=f"Control '{control.name}' has corrupted data",
1160-
resource="Control",
1161-
hint="Update the control data using PUT /{control_id}/data.",
1162-
errors=[
1163-
ValidationErrorItem(
1164-
resource="Control",
1165-
field="data",
1166-
code="corrupted_data",
1167-
message=_CORRUPTED_CONTROL_DATA_MESSAGE,
1168-
)
1169-
],
1170-
)
1137+
if _is_unrendered_template(control.data):
1138+
if request.enabled:
1139+
raise APIValidationError(
1140+
error_code=ErrorCode.VALIDATION_ERROR,
1141+
detail=(
1142+
f"Cannot enable control '{control.name}': "
1143+
"unrendered template controls must be rendered first"
1144+
),
1145+
resource="Control",
1146+
hint=(
1147+
"Provide complete parameter values via "
1148+
f"PUT /api/v1/controls/{control_id}/data "
1149+
"to render the template before enabling."
1150+
),
1151+
errors=[
1152+
ValidationErrorItem(
1153+
resource="Control",
1154+
field="enabled",
1155+
code="unrendered_template_cannot_enable",
1156+
message=(
1157+
"Provide parameter values to render the "
1158+
"template before enabling."
1159+
),
1160+
)
1161+
],
1162+
)
1163+
# enabled=False on an unrendered template is a no-op (already false).
1164+
current_enabled = False
1165+
else:
1166+
try:
1167+
ctrl_def = ControlDefinition.model_validate(control.data)
1168+
if ctrl_def.enabled != request.enabled:
1169+
new_data = dict(control.data)
1170+
new_data["enabled"] = request.enabled
1171+
control.data = new_data
1172+
updated = True
1173+
current_enabled = request.enabled if updated else ctrl_def.enabled
1174+
except ValidationError:
1175+
_logger.error(
1176+
"Control '%s' (%s) has corrupted data in patch request",
1177+
control.name,
1178+
control_id,
1179+
exc_info=True,
1180+
)
1181+
raise APIValidationError(
1182+
error_code=ErrorCode.CORRUPTED_DATA,
1183+
detail=f"Control '{control.name}' has corrupted data",
1184+
resource="Control",
1185+
hint="Update the control data using PUT /{control_id}/data.",
1186+
errors=[
1187+
ValidationErrorItem(
1188+
resource="Control",
1189+
field="data",
1190+
code="corrupted_data",
1191+
message=_CORRUPTED_CONTROL_DATA_MESSAGE,
1192+
)
1193+
],
1194+
)
11711195
elif control.data:
11721196
# Get current enabled status for response
11731197
try:

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

Lines changed: 116 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -525,12 +525,57 @@ def _reject_agent_scoped_evaluators(
525525
)
526526

527527

528+
def _collect_param_references(
529+
value: JsonValue,
530+
*,
531+
path_parts: list[str | int],
532+
template: TemplateDefinition,
533+
referenced: set[str],
534+
) -> None:
535+
"""Walk definition_template collecting $param references and validating bindings."""
536+
if isinstance(value, dict):
537+
if "$param" in value:
538+
if len(value) != 1 or not isinstance(value["$param"], str):
539+
raise _render_error(
540+
detail="Invalid $param binding object in template definition",
541+
field=_format_rendered_path(path_parts),
542+
code="invalid_param_binding",
543+
message="A $param binding must be exactly {'$param': '<parameter_name>'}.",
544+
)
545+
parameter_name = value["$param"]
546+
if parameter_name not in template.parameters:
547+
raise _render_error(
548+
detail=f"Template references undefined parameter '{parameter_name}'",
549+
field=_format_rendered_path(path_parts),
550+
code="undefined_parameter_reference",
551+
message=f"Template references undefined parameter '{parameter_name}'.",
552+
)
553+
referenced.add(parameter_name)
554+
return
555+
556+
for key, nested in value.items():
557+
_collect_param_references(
558+
nested,
559+
path_parts=[*path_parts, key],
560+
template=template,
561+
referenced=referenced,
562+
)
563+
elif isinstance(value, list):
564+
for idx, nested in enumerate(value):
565+
_collect_param_references(
566+
nested,
567+
path_parts=[*path_parts, idx],
568+
template=template,
569+
referenced=referenced,
570+
)
571+
572+
528573
def validate_template_structure(template: TemplateDefinition) -> None:
529574
"""Validate a template definition's structure without rendering.
530575
531-
Checks forbidden top-level keys, legacy format, and that ``definition_template``
532-
is a dict. Used for unrendered template creation where parameter values are
533-
not yet available.
576+
Performs all structural checks that don't require parameter values:
577+
forbidden top-level keys, legacy format, $param reference validity,
578+
unused parameter detection, and agent-scoped evaluator rejection.
534579
"""
535580
definition_template = template.definition_template
536581
if not isinstance(definition_template, dict):
@@ -566,6 +611,74 @@ def validate_template_structure(template: TemplateDefinition) -> None:
566611
),
567612
)
568613

614+
# Walk the template to validate $param references and collect referenced params.
615+
referenced: set[str] = set()
616+
_collect_param_references(
617+
definition_template,
618+
path_parts=[],
619+
template=template,
620+
referenced=referenced,
621+
)
622+
623+
# Reject unused parameters.
624+
unused = sorted(set(template.parameters) - referenced)
625+
if unused:
626+
raise APIValidationError(
627+
error_code=ErrorCode.TEMPLATE_RENDER_ERROR,
628+
detail="Template defines parameters that are never referenced",
629+
resource="Control",
630+
hint="Remove unused parameters or reference them in definition_template.",
631+
errors=[
632+
ValidationErrorItem(
633+
resource="Control",
634+
field=f"template.parameters.{name}",
635+
code="unused_template_parameter",
636+
message=f"Template parameter '{name}' is never referenced.",
637+
parameter=name,
638+
parameter_label=template.parameters[name].label,
639+
)
640+
for name in unused
641+
],
642+
)
643+
644+
# Reject agent-scoped evaluator names baked into the template (not via $param).
645+
_reject_hardcoded_agent_scoped_evaluators(definition_template)
646+
647+
648+
def _reject_hardcoded_agent_scoped_evaluators(
649+
definition_template: dict[str, JsonValue],
650+
) -> None:
651+
"""Reject agent-scoped evaluator names that are hardcoded in the template."""
652+
# Walk condition tree looking for evaluator.name fields containing ':'
653+
condition = definition_template.get("condition")
654+
if not isinstance(condition, dict):
655+
return
656+
657+
stack: list[dict[str, JsonValue]] = [condition]
658+
while stack:
659+
node = stack.pop()
660+
evaluator = node.get("evaluator")
661+
if isinstance(evaluator, dict):
662+
name = evaluator.get("name")
663+
if isinstance(name, str) and ":" in name:
664+
raise _render_error(
665+
detail="Agent-scoped evaluators are not supported in control templates",
666+
field="condition.evaluator.name",
667+
code="agent_scoped_evaluator_not_supported",
668+
message="Agent-scoped evaluators are not supported in control templates.",
669+
)
670+
671+
for key in ("and", "or"):
672+
children = node.get(key)
673+
if isinstance(children, list):
674+
for child in children:
675+
if isinstance(child, dict):
676+
stack.append(child)
677+
678+
not_child = node.get("not")
679+
if isinstance(not_child, dict):
680+
stack.append(not_child)
681+
569682

570683
def render_template_control_input(
571684
template_input: TemplateControlInput,

‎server/tests/test_control_templates.py‎

Lines changed: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1658,7 +1658,97 @@ def test_unrendered_template_can_be_deleted(client: TestClient) -> None:
16581658
assert response.json()["success"] is True
16591659

16601660

1661-
def test_rendered_template_then_update_to_unrendered_stays_rendered(
1661+
def test_patch_disable_on_unrendered_template_is_noop(client: TestClient) -> None:
1662+
# Given: an unrendered template control (already enabled=false)
1663+
control_id, _ = _create_unrendered_control(client)
1664+
1665+
# When: patching enabled=false (redundant, but should not crash)
1666+
response = client.patch(
1667+
f"/api/v1/controls/{control_id}",
1668+
json={"enabled": False},
1669+
)
1670+
1671+
# Then: the request succeeds without error (no CORRUPTED_DATA)
1672+
assert response.status_code == 200, response.text
1673+
assert response.json()["enabled"] is False
1674+
1675+
1676+
def test_create_unrendered_template_rejects_undefined_param_reference(
1677+
client: TestClient,
1678+
) -> None:
1679+
# Given: a template whose definition_template references an undefined parameter
1680+
payload = _unrendered_template_payload()
1681+
payload["template"]["definition_template"]["condition"]["evaluator"]["config"]["extra"] = { # type: ignore[index]
1682+
"$param": "nonexistent",
1683+
}
1684+
1685+
# When: creating the unrendered control
1686+
response = client.put(
1687+
"/api/v1/controls",
1688+
json={"name": f"bad-unrendered-{uuid.uuid4()}", "data": payload},
1689+
)
1690+
1691+
# Then: the server rejects with a structural validation error
1692+
assert response.status_code == 422
1693+
body = response.json()
1694+
assert body["error_code"] == "TEMPLATE_RENDER_ERROR"
1695+
assert any(
1696+
err.get("code") == "undefined_parameter_reference"
1697+
for err in body.get("errors", [])
1698+
)
1699+
1700+
1701+
def test_create_unrendered_template_rejects_unused_parameter(
1702+
client: TestClient,
1703+
) -> None:
1704+
# Given: a template with an extra parameter never referenced in definition_template
1705+
payload = _unrendered_template_payload()
1706+
payload["template"]["parameters"]["unused_param"] = { # type: ignore[index]
1707+
"type": "string",
1708+
"label": "Unused",
1709+
"default": "val",
1710+
}
1711+
1712+
# When: creating the unrendered control
1713+
response = client.put(
1714+
"/api/v1/controls",
1715+
json={"name": f"unused-param-{uuid.uuid4()}", "data": payload},
1716+
)
1717+
1718+
# Then: the server rejects with unused parameter error
1719+
assert response.status_code == 422
1720+
body = response.json()
1721+
assert body["error_code"] == "TEMPLATE_RENDER_ERROR"
1722+
assert any(
1723+
err.get("code") == "unused_template_parameter"
1724+
for err in body.get("errors", [])
1725+
)
1726+
1727+
1728+
def test_create_unrendered_template_rejects_agent_scoped_evaluator(
1729+
client: TestClient,
1730+
) -> None:
1731+
# Given: a template with a hardcoded agent-scoped evaluator name
1732+
payload = _unrendered_template_payload()
1733+
payload["template"]["definition_template"]["condition"]["evaluator"]["name"] = "agent-x:custom" # type: ignore[index]
1734+
1735+
# When: creating the unrendered control
1736+
response = client.put(
1737+
"/api/v1/controls",
1738+
json={"name": f"agent-scoped-{uuid.uuid4()}", "data": payload},
1739+
)
1740+
1741+
# Then: the server rejects
1742+
assert response.status_code == 422
1743+
body = response.json()
1744+
assert body["error_code"] == "TEMPLATE_RENDER_ERROR"
1745+
assert any(
1746+
err.get("code") == "agent_scoped_evaluator_not_supported"
1747+
for err in body.get("errors", [])
1748+
)
1749+
1750+
1751+
def test_rendered_template_rejects_update_with_incomplete_values(
16621752
client: TestClient,
16631753
) -> None:
16641754
# Given: a rendered template control
@@ -1670,9 +1760,13 @@ def test_rendered_template_then_update_to_unrendered_stays_rendered(
16701760
json={"data": _unrendered_template_payload()},
16711761
)
16721762

1673-
# Then: the control becomes unrendered (stored without rendered fields)
1674-
assert put_response.status_code == 200, put_response.text
1763+
# Then: the server rejects (missing required parameter for a rendered control)
1764+
assert put_response.status_code == 422
1765+
body = put_response.json()
1766+
assert body["error_code"] == "TEMPLATE_PARAMETER_INVALID"
1767+
1768+
# And: the control remains rendered and unchanged
16751769
get_response = client.get(f"/api/v1/controls/{control_id}/data")
16761770
data = get_response.json()["data"]
1677-
assert data["enabled"] is False
1678-
assert "condition" not in data
1771+
assert "condition" in data
1772+
assert data["condition"]["evaluator"]["config"]["pattern"] == "hello"

0 commit comments

Comments
 (0)