Skip to content

Commit b965e66

Browse files
authored
refactor(models): reject legacy action values at API boundaries (#163)
## Summary - Split action validation into strict (`validate_action`) for API input boundaries and lenient (`normalize_action`) for internal read paths - `ControlAction.decision` now rejects `allow`/`warn`/`log` with a clear error — only `deny`, `steer`, `observe` are accepted when creating or updating controls - `EventQueryRequest.actions` now rejects legacy values in query filters - `ControlMatch` and `ControlExecutionEvent` still normalize legacy values from historical DB rows via `normalize_action` - `expand_action_filter` unchanged — still expands `observe` to include legacy values for SQL queries against old event data Follows #161 which introduced the `observe` action and normalization layer. ## Test plan - [x] `models/tests/test_actions.py` — 43 tests covering strict validation, lenient normalization, Pydantic model boundaries, and query expansion - [x] `server/tests/test_observability_models.py` — updated to verify legacy rejection in query filters - [x] `engine/tests/test_core.py` — 49 tests pass (unchanged) - [x] `sdks/python/tests/test_control_decorators.py` — 35 tests pass (unchanged) - [x] `make lint` and `make typecheck` pass 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 275d305 commit b965e66

8 files changed

Lines changed: 317 additions & 79 deletions

File tree

models/src/agent_control_models/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
expand_action_filter,
1313
normalize_action,
1414
normalize_action_list,
15+
validate_action,
16+
validate_action_list,
1517
)
1618
from .agent import (
1719
BUILTIN_STEP_TYPES,
@@ -144,6 +146,8 @@
144146
"UnrenderedTemplateControl",
145147
"normalize_action",
146148
"normalize_action_list",
149+
"validate_action",
150+
"validate_action_list",
147151
"expand_action_filter",
148152
# Error models
149153
"ProblemDetail",

models/src/agent_control_models/actions.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
type ActionDecision = Literal["deny", "steer", "observe"]
99

10+
_CANONICAL_ACTIONS = frozenset({"deny", "steer", "observe"})
1011
_OBSERVE_ACTION_ALIASES = frozenset({"allow", "observe", "warn", "log"})
1112
_ACTION_QUERY_EXPANSION: dict[ActionDecision, tuple[str, ...]] = {
1213
"deny": ("deny",),
@@ -15,15 +16,44 @@
1516
}
1617

1718

19+
def validate_action(action: str) -> ActionDecision:
20+
"""Validate that *action* is one of the canonical action values.
21+
22+
Use this on public API boundaries (control create/update, query filters)
23+
where legacy values should be rejected.
24+
"""
25+
if action in _CANONICAL_ACTIONS:
26+
return cast(ActionDecision, action)
27+
raise ValueError(
28+
f"Invalid action {action!r}. Must be one of: deny, steer, observe."
29+
)
30+
31+
32+
def validate_action_list(actions: Sequence[str]) -> list[ActionDecision]:
33+
"""Validate a list of actions, preserving order and removing duplicates."""
34+
validated: list[ActionDecision] = []
35+
seen: set[ActionDecision] = set()
36+
for action in actions:
37+
canonical = validate_action(action)
38+
if canonical in seen:
39+
continue
40+
seen.add(canonical)
41+
validated.append(canonical)
42+
return validated
43+
44+
1845
def normalize_action(action: str) -> ActionDecision:
19-
"""Normalize a public or legacy action name to the canonical action."""
46+
"""Normalize a stored or legacy action name to the canonical action.
47+
48+
Use this on internal read paths (deserializing DB rows, server responses)
49+
where historical data may contain legacy values.
50+
"""
2051
if action in _OBSERVE_ACTION_ALIASES:
2152
return "observe"
2253
if action in ("deny", "steer"):
2354
return cast(ActionDecision, action)
2455
raise ValueError(
25-
"Invalid action. Expected one of: deny, steer, observe "
26-
"(legacy aliases allow/warn/log are also accepted temporarily)."
56+
f"Invalid action {action!r}. Expected one of: deny, steer, observe."
2757
)
2858

2959

models/src/agent_control_models/controls.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import re2
1212
from pydantic import ConfigDict, Field, ValidationInfo, field_validator, model_validator
1313

14-
from .actions import ActionDecision, normalize_action
14+
from .actions import ActionDecision, normalize_action, validate_action
1515
from .agent import JSONValue
1616
from .base import BaseModel
1717

@@ -520,8 +520,8 @@ class ControlAction(BaseModel):
520520

521521
@field_validator("decision", mode="before")
522522
@classmethod
523-
def normalize_decision(cls, value: str) -> ActionDecision:
524-
return normalize_action(value)
523+
def validate_decision(cls, value: str) -> ActionDecision:
524+
return validate_action(value)
525525

526526

527527
MAX_CONDITION_DEPTH = 6

models/src/agent_control_models/observability.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@
1414

1515
from pydantic import Field, field_validator
1616

17-
from .actions import ActionDecision, normalize_action, normalize_action_list
17+
from .actions import (
18+
ActionDecision,
19+
normalize_action,
20+
validate_action_list,
21+
)
1822
from .agent import AGENT_NAME_MIN_LENGTH, AGENT_NAME_PATTERN, normalize_agent_name
1923
from .base import BaseModel
2024

@@ -343,12 +347,12 @@ def validate_and_normalize_agent_name(
343347

344348
@field_validator("actions", mode="before")
345349
@classmethod
346-
def normalize_actions_filter(
350+
def validate_actions_filter(
347351
cls, value: list[str] | None
348352
) -> list[ActionDecision] | None:
349353
if value is None:
350354
return None
351-
return normalize_action_list(value)
355+
return validate_action_list(value)
352356

353357

354358
class EventQueryResponse(BaseModel):

0 commit comments

Comments
 (0)