fix: validate closed-set preset params to reject typos (fixes #4116)

Seven Agent(...) preset params (output, execution, context, autonomy,
approval, learn, self_improve) silently fell back to defaults on a typo'd
preset name. Worst case: approval="read_onl" produced an empty deny set,
allowing all 17 dangerous tools instead of the intended read-only sandbox.

Wire the existing make_preset_error helper to these params via a small
validate_preset_string() in parse_utils.py plus a compact
_PRESET_STRING_PARAMS table and _validate_preset_params() loop run at the
top of the consolidated-params block, before every fast path that could
swallow the value. Free-form string params (knowledge, guardrails, skills,
runtime, auth) are deliberately excluded.

Co-authored-by: MervinPraison <[email protected]>
This commit is contained in:
praisonai-triage-agent[bot]andMervinPraison committed 2026-08-20 14:05:30 +00:00
1 parent 680bfb3555
commit 351e30c093
3 files changed
+175 -1

No files matched your search

@@ -124,6 +124,7 @@ from ..config.param_resolver import resolve, ArrayMode
from ..config.presets import (
OUTPUT_PRESETS, EXECUTION_PRESETS, MEMORY_PRESETS, MEMORY_URL_SCHEMES,
WEB_PRESETS, PLANNING_PRESETS, REFLECTION_PRESETS, CACHING_PRESETS,
CONTEXT_PRESETS, AUTONOMY_PRESETS,
DEFAULT_OUTPUT_MODE,
)
from ..config.feature_configs import (
@@ -138,6 +139,64 @@ from ..config.feature_configs import (
# (re-exported for backward compatibility) so OutputConfig.tool_output_limit,
# ToolConfig.output_limit, and this constant cannot drift apart.
# ============================================================================
# Preset-string validation
# ============================================================================
# Agent params whose string form must name a preset from a CLOSED set. Each
# entry maps the param name to a zero-arg callable returning the accepted
# names, so registries outside praisonaiagents.config stay lazily imported —
# the table is only consulted when the caller actually passed a string.
#
# Deliberately NOT listed (their string form is free-form, not a preset):
# knowledge= (source path/URL), guardrails= (LLM validator prompt),
# skills= (skill path/name), runtime= (open plugin registry), auth= (open
# provider registry validated loudly at use time).
def _approval_preset_names():
"""Accepted approval= strings: deny-set presets + PermissionMode aliases."""
from ..approval.registry import PERMISSION_PRESETS
from ..permissions.rules import _MODE_ALIASES
# "true"/"false" are legacy stringified bools accepted by the branch below.
return set(PERMISSION_PRESETS) | set(_MODE_ALIASES) | {"true", "false"}
_PRESET_STRING_PARAMS = {
"output": lambda: OUTPUT_PRESETS.keys(),
"execution": lambda: EXECUTION_PRESETS.keys(),
"context": lambda: CONTEXT_PRESETS.keys(),
"autonomy": lambda: AUTONOMY_PRESETS.keys(),
"approval": _approval_preset_names,
# Mirrors the LearnMode branch in __init__.
"learn": lambda: ("disabled", "agentic", "propose"),
# Mirrors the self-improve mode branch in __init__.
"self_improve": lambda: (
"inline", "blocking", "sync", "background", "async",
"true", "on", "yes", "1", "false", "off", "no", "0",
),
}
def _validate_preset_params(params):
"""Fail loudly on a typo'd preset name instead of silently using defaults.
Args:
params: Mapping of param name -> raw value. Non-string values are
ignored; string values must name a preset from the closed set
declared in ``_PRESET_STRING_PARAMS``.
Raises:
ValueError: With a "Did you mean ...?" suggestion and the valid names.
"""
from ..config.parse_utils import validate_preset_string
for name, value in params.items():
if not isinstance(value, str):
continue
# output= doubles as an output-file path (output="report.md").
if name == "output" and _is_file_path(value):
continue
validate_preset_string(name, value, _PRESET_STRING_PARAMS[name]())
class ServerRegistry:
"""Registry for API server state per-port."""
@@ -945,7 +1004,20 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
# Uses unified resolver: Instance > Config > Array > String > Bool > Default
# Note: Imports moved to module level for performance
# ============================================================
# Reject a typo'd preset name (e.g. output="streem", approval="read_onl")
# before any of the fast paths below silently fall back to defaults.
# memory/planning/reflection/web/caching/tool_search already raise via
# their own resolvers; this covers the params that did not.
_validate_preset_params({
"output": output,
"execution": execution,
"context": context,
"autonomy": autonomy,
"approval": approval,
"learn": learn,
"self_improve": self_improve,
})
# Initialize extracted values with defaults
user_id = None
session_id = None
@@ -195,6 +195,39 @@ def make_preset_error(
return ValueError(" ".join(msg_parts))
def validate_preset_string(
param_name: str,
value: Any,
presets: Iterable[str],
) -> None:
"""
Raise a helpful error when a string does not name a known preset.
Non-string values pass through untouched, so callers can hand this the raw
parameter without pre-checking the type. Matching is case-insensitive and
ignores surrounding whitespace, mirroring the preset lookups performed by
the parameter resolvers.
Args:
param_name: Name of the parameter (for the error message)
value: Raw parameter value
presets: Valid preset names
Raises:
ValueError: If value is a string that is not a known preset
"""
if not isinstance(value, str):
return
normalized = value.strip().lower()
presets_list = list(presets)
for preset in presets_list:
if preset.strip().lower() == normalized:
return
raise make_preset_error(param_name, value, presets_list)
def is_policy_string(value: str) -> bool:
"""
Check if a string is a policy specification. O(1) operation.
@@ -0,0 +1,69 @@
"""Preset-name typos must raise, not silently fall back to defaults.
Regression test: Agent(output="streem") used to construct successfully with
verbose=False (the silent default) instead of reporting the typo.
"""
import pytest
from praisonaiagents import Agent
# (param, typo, expected suggestion in the error message)
TYPOED_PRESETS = [
("output", "streem", "stream"),
("execution", "balancd", "balanced"),
("context", "sliding_windo", "sliding_window"),
("autonomy", "full_atuo", "full_auto"),
("approval", "read_onl", "read_only"),
("learn", "agentik", "agentic"),
("self_improve", "backgrond", "background"),
# Already-validated params — kept here so the guarantee is uniform.
("memory", "postgress", "postgres"),
("reflection", "thorogh", "thorough"),
]
@pytest.mark.parametrize("param,typo,suggestion", TYPOED_PRESETS)
def test_typoed_preset_raises_with_suggestion(param, typo, suggestion):
with pytest.raises(ValueError) as exc:
Agent(instructions="t", **{param: typo})
msg = str(exc.value)
assert typo in msg
assert suggestion in msg
VALID_PRESETS = [
("output", "verbose"),
("output", "silent"),
("output", "VERBOSE"), # case-insensitive lookup
("execution", "fast"),
("execution", "THOROUGH"),
("context", "summarize"),
("autonomy", "full_auto"),
("approval", "read_only"),
("approval", "plan"),
("learn", "disabled"),
("self_improve", "background"),
]
@pytest.mark.parametrize("param,value", VALID_PRESETS)
def test_valid_preset_still_accepted(param, value):
assert Agent(instructions="t", **{param: value}) is not None
def test_output_still_accepts_a_file_path():
"""output= doubles as an output-file path; that escape hatch must survive."""
agent = Agent(instructions="t", output="report.md")
assert agent._output_file == "report.md"
@pytest.mark.parametrize("value", [True, False, {"verbose": True}, None])
def test_non_string_output_untouched(value):
assert Agent(instructions="t", output=value) is not None
def test_freeform_string_params_are_not_validated():
"""These take arbitrary identifiers, not presets — they must NOT raise."""
assert Agent(instructions="t", knowledge="notes.pdf") is not None
assert Agent(instructions="t", guardrails="Answer must cite a source.") is not None
assert Agent(instructions="t", runtime="my-plugin-runtime") is not None