mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 01:08:39 -10:00
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:
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
|
||||
Reference in new issue
Block a user