fix(subagents): reject Windows reserved device names in managed subagent creation (#7959)

* fix(subagents): reject Windows reserved device names in managed subagent creation

* Reject Windows reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
  in normalize_managed_subagent_name when for_creation=True via
  windows_incompatible_segment.
* Validate subagent names on creation in FileManagedSubagentStore.create
  and /api/subagents POST gateway router before attempting disk writes.
* Keep read, update, and delete paths permissive for backward compatibility.
* Add unit tests for store validation and router endpoints.
* Update CHANGELOG.md under Fixed.

* fix(subagents): reject reserved names in SQL managed-subagent create

Apply the same for_creation check used by the file store so both
backends refuse Windows device names. Pin mixed-case Nul in tests and
import the shared normalizer at module level.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Eric 2026-11-08 21:55:46 +08:00 committed by GitHub
parent a73a94a6a3
commit 3920169fb6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 66 additions and 3 deletions

View File

@ -28,6 +28,13 @@ This section accumulates work toward the **2.3.0** milestone
### Fixed
- **subagents:** Managed subagent creation now rejects Windows reserved device
names (`CON`, `PRN`, `AUX`, `NUL`, `COM1-9`, `LPT1-9`) at the shared
`normalize_managed_subagent_name(..., for_creation=True)` validator and
Gateway `/api/subagents` boundary before attempting filesystem writes,
preventing `OSError` failures on Windows hosts while keeping existing
read and delete operations permissive.
- **scheduler:** One scheduled task whose saved schedule can no longer be
computed no longer stops every other task. When a stored timezone or cron
expression stopped working (a zone missing from the time zone database
@ -36,6 +43,7 @@ This section accumulates work toward the **2.3.0** milestone
expired, every later poll failed before admitting any due task. That task
is now paused with the reason as its last error, and resuming it reports
the problem until the schedule is fixed. ([#7993])
- **community:** A search or fetch API key that ends in a newline no longer
leaks into the conversation. GroundRoute (`GROUNDROUTE_API_KEY`), InfoQuest,
Tavily, Exa, Jina, Firecrawl, fastCRW and Crawl4AI put the key into an HTTP

View File

@ -88,9 +88,9 @@ def _validate_model(model: str, app_config) -> None:
raise HTTPException(status_code=422, detail=f"Unknown model '{model}'. Use 'inherit' or a configured model name.")
def _validate_path_name(name: str) -> str:
def _validate_path_name(name: str, *, for_creation: bool = False) -> str:
try:
return normalize_managed_subagent_name(name)
return normalize_managed_subagent_name(name, for_creation=for_creation)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@ -181,6 +181,7 @@ async def list_subagents(request: Request) -> SubagentsListResponse:
@router.post("", response_model=SubagentResponse, status_code=201, summary="Create Managed Subagent")
async def create_managed_subagent(request: Request, body: ManagedSubagentCreateRequest) -> SubagentResponse:
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
_validate_path_name(body.name, for_creation=True)
try:
definition = ManagedSubagentDefinition(**body.model_dump(exclude_none=True))
except ValidationError as exc:

View File

@ -8,14 +8,20 @@ from collections.abc import Hashable
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from deerflow.utils.host_paths import windows_incompatible_segment
REQUIRED_DISALLOWED_TOOLS = frozenset({"task", "ask_clarification", "present_files"})
MANAGED_SUBAGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$")
def normalize_managed_subagent_name(value: str) -> str:
def normalize_managed_subagent_name(value: str, *, for_creation: bool = False) -> str:
"""Validate and normalize a managed subagent natural key."""
if not isinstance(value, str) or not MANAGED_SUBAGENT_NAME_PATTERN.fullmatch(value):
raise ValueError(f"Invalid managed subagent name {value!r}. Must match {MANAGED_SUBAGENT_NAME_PATTERN.pattern}")
if for_creation:
reason = windows_incompatible_segment(value)
if reason is not None:
raise ValueError(f"Invalid managed subagent name {value!r}: {reason}")
return value.lower()

View File

@ -49,6 +49,7 @@ class FileManagedSubagentStore(ManagedSubagentStore):
return sorted(definitions, key=lambda item: item.name)
def create(self, definition: ManagedSubagentDefinition) -> None:
normalize_managed_subagent_name(definition.name, for_creation=True)
path = get_paths().managed_subagent_file(definition.name)
with _write_lock:
if path.exists():

View File

@ -44,6 +44,7 @@ class SqlManagedSubagentStore(ManagedSubagentStore):
return [ManagedSubagentDefinition.model_validate(row.definition) for row in rows]
def create(self, definition: ManagedSubagentDefinition) -> None:
normalize_managed_subagent_name(definition.name, for_creation=True)
row = ManagedSubagentRow(
id=uuid.uuid4().hex,
name=definition.name,

View File

@ -15,6 +15,7 @@ from deerflow.persistence.managed_subagents import (
ManagedSubagentExistsError,
make_managed_subagent_store,
)
from deerflow.persistence.managed_subagents.base import normalize_managed_subagent_name
from deerflow.persistence.managed_subagents.file import FileManagedSubagentStore
from deerflow.persistence.managed_subagents.model import ManagedSubagentRow
from deerflow.persistence.managed_subagents.sql import SqlManagedSubagentStore
@ -158,3 +159,26 @@ def test_store_factory_rejects_memory_database():
)
with pytest.raises(ValueError, match="sqlite.*postgres"):
make_managed_subagent_store(config)
@pytest.mark.parametrize("name", ["nul", "NUL", "Nul", "con", "prn", "aux", "com1", "lpt9"])
def test_normalize_managed_subagent_name_rejects_windows_reserved_names(name: str):
with pytest.raises(ValueError, match="reserved Windows device name"):
normalize_managed_subagent_name(name, for_creation=True)
@pytest.mark.parametrize("name", ["nul", "con", "com1"])
def test_normalize_managed_subagent_name_allows_reserved_names_on_read(name: str):
assert normalize_managed_subagent_name(name) == name.lower()
@pytest.mark.parametrize("name", ["planner", "code-reviewer-2", "researcher"])
def test_normalize_managed_subagent_name_accepts_valid_names(name: str):
assert normalize_managed_subagent_name(name, for_creation=True) == name
@pytest.mark.parametrize("store_fixture", ["file_store", "sql_store"])
def test_store_rejects_windows_reserved_device_names(request, store_fixture):
store = request.getfixturevalue(store_fixture)
with pytest.raises(ValueError, match="reserved Windows device name"):
store.create(_definition("nul"))

View File

@ -320,3 +320,25 @@ async def test_invalid_path_name_returns_422(operation: str):
else:
await router.delete_managed_subagent("../planner", _request("admin"))
assert excinfo.value.status_code == 422
@pytest.mark.parametrize("name", ["nul", "NUL", "Nul", "con", "prn", "aux", "com1", "lpt9"])
async def test_create_managed_subagent_rejects_windows_reserved_names(name: str):
with pytest.raises(HTTPException) as excinfo:
await router.create_managed_subagent(
_request("admin"),
router.ManagedSubagentCreateRequest(
name=name,
description="Reserved name",
system_prompt="Should fail.",
),
)
assert excinfo.value.status_code == 422
assert "reserved Windows device name" in str(excinfo.value.detail)
@pytest.mark.parametrize("name", ["nul", "con", "com1"])
async def test_delete_managed_subagent_allows_windows_reserved_names(name: str):
with pytest.raises(HTTPException) as excinfo:
await router.delete_managed_subagent(name, _request("admin"))
assert excinfo.value.status_code == 404