mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-04-25 11:18:22 +00:00
fix: disable custom-agent management API by default (#2161)
* fix: disable custom-agent management API by default * style: format agents API hardening files * fix: address review feedback for agents API hardening * fix: add missing disabled API coverage
This commit is contained in:
parent
f4c17c66ce
commit
a7e7c6d667
@ -8,6 +8,7 @@ import yaml
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from deerflow.config.agents_api_config import get_agents_api_config
|
||||
from deerflow.config.agents_config import AgentConfig, list_custom_agents, load_agent_config, load_agent_soul
|
||||
from deerflow.config.paths import get_paths
|
||||
|
||||
@ -73,6 +74,15 @@ def _normalize_agent_name(name: str) -> str:
|
||||
return name.lower()
|
||||
|
||||
|
||||
def _require_agents_api_enabled() -> None:
|
||||
"""Reject access unless the custom-agent management API is explicitly enabled."""
|
||||
if not get_agents_api_config().enabled:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=("Custom-agent management API is disabled. Set agents_api.enabled=true to expose agent and user-profile routes over HTTP."),
|
||||
)
|
||||
|
||||
|
||||
def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False) -> AgentResponse:
|
||||
"""Convert AgentConfig to AgentResponse."""
|
||||
soul: str | None = None
|
||||
@ -100,6 +110,8 @@ async def list_agents() -> AgentsListResponse:
|
||||
Returns:
|
||||
List of all custom agents with their metadata and soul content.
|
||||
"""
|
||||
_require_agents_api_enabled()
|
||||
|
||||
try:
|
||||
agents = list_custom_agents()
|
||||
return AgentsListResponse(agents=[_agent_config_to_response(a, include_soul=True) for a in agents])
|
||||
@ -125,6 +137,7 @@ async def check_agent_name(name: str) -> dict:
|
||||
Raises:
|
||||
HTTPException: 422 if the name is invalid.
|
||||
"""
|
||||
_require_agents_api_enabled()
|
||||
_validate_agent_name(name)
|
||||
normalized = _normalize_agent_name(name)
|
||||
available = not get_paths().agent_dir(normalized).exists()
|
||||
@ -149,6 +162,7 @@ async def get_agent(name: str) -> AgentResponse:
|
||||
Raises:
|
||||
HTTPException: 404 if agent not found.
|
||||
"""
|
||||
_require_agents_api_enabled()
|
||||
_validate_agent_name(name)
|
||||
name = _normalize_agent_name(name)
|
||||
|
||||
@ -181,6 +195,7 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
|
||||
Raises:
|
||||
HTTPException: 409 if agent already exists, 422 if name is invalid.
|
||||
"""
|
||||
_require_agents_api_enabled()
|
||||
_validate_agent_name(request.name)
|
||||
normalized_name = _normalize_agent_name(request.name)
|
||||
|
||||
@ -243,6 +258,7 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
|
||||
Raises:
|
||||
HTTPException: 404 if agent not found.
|
||||
"""
|
||||
_require_agents_api_enabled()
|
||||
_validate_agent_name(name)
|
||||
name = _normalize_agent_name(name)
|
||||
|
||||
@ -315,6 +331,8 @@ async def get_user_profile() -> UserProfileResponse:
|
||||
Returns:
|
||||
UserProfileResponse with content=None if USER.md does not exist yet.
|
||||
"""
|
||||
_require_agents_api_enabled()
|
||||
|
||||
try:
|
||||
user_md_path = get_paths().user_md_file
|
||||
if not user_md_path.exists():
|
||||
@ -341,6 +359,8 @@ async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileR
|
||||
Returns:
|
||||
UserProfileResponse with the saved content.
|
||||
"""
|
||||
_require_agents_api_enabled()
|
||||
|
||||
try:
|
||||
paths = get_paths()
|
||||
paths.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
@ -367,6 +387,7 @@ async def delete_agent(name: str) -> None:
|
||||
Raises:
|
||||
HTTPException: 404 if agent not found.
|
||||
"""
|
||||
_require_agents_api_enabled()
|
||||
_validate_agent_name(name)
|
||||
name = _normalize_agent_name(name)
|
||||
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
"""Configuration for the custom agents management API."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AgentsApiConfig(BaseModel):
|
||||
"""Configuration for custom-agent and user-profile management routes."""
|
||||
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
description=("Whether to expose the custom-agent management API over HTTP. When disabled, the gateway rejects read/write access to custom agent SOUL.md, config, and USER.md prompt-management routes."),
|
||||
)
|
||||
|
||||
|
||||
_agents_api_config: AgentsApiConfig = AgentsApiConfig()
|
||||
|
||||
|
||||
def get_agents_api_config() -> AgentsApiConfig:
|
||||
"""Get the current agents API configuration."""
|
||||
return _agents_api_config
|
||||
|
||||
|
||||
def set_agents_api_config(config: AgentsApiConfig) -> None:
|
||||
"""Set the agents API configuration."""
|
||||
global _agents_api_config
|
||||
_agents_api_config = config
|
||||
|
||||
|
||||
def load_agents_api_config_from_dict(config_dict: dict) -> None:
|
||||
"""Load agents API configuration from a dictionary."""
|
||||
global _agents_api_config
|
||||
_agents_api_config = AgentsApiConfig(**config_dict)
|
||||
@ -9,6 +9,7 @@ from dotenv import load_dotenv
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from deerflow.config.acp_config import load_acp_config_from_dict
|
||||
from deerflow.config.agents_api_config import AgentsApiConfig, load_agents_api_config_from_dict
|
||||
from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict
|
||||
from deerflow.config.extensions_config import ExtensionsConfig
|
||||
from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
|
||||
@ -60,6 +61,7 @@ class AppConfig(BaseModel):
|
||||
title: TitleConfig = Field(default_factory=TitleConfig, description="Automatic title generation configuration")
|
||||
summarization: SummarizationConfig = Field(default_factory=SummarizationConfig, description="Conversation summarization configuration")
|
||||
memory: MemoryConfig = Field(default_factory=MemoryConfig, description="Memory subsystem configuration")
|
||||
agents_api: AgentsApiConfig = Field(default_factory=AgentsApiConfig, description="Custom-agent management API configuration")
|
||||
subagents: SubagentsAppConfig = Field(default_factory=SubagentsAppConfig, description="Subagent runtime configuration")
|
||||
guardrails: GuardrailsConfig = Field(default_factory=GuardrailsConfig, description="Guardrail middleware configuration")
|
||||
circuit_breaker: CircuitBreakerConfig = Field(default_factory=CircuitBreakerConfig, description="LLM circuit breaker configuration")
|
||||
@ -125,6 +127,10 @@ class AppConfig(BaseModel):
|
||||
if "memory" in config_data:
|
||||
load_memory_config_from_dict(config_data["memory"])
|
||||
|
||||
# Always refresh agents API config so removed config sections reset
|
||||
# singleton-backed state to its default/disabled values on reload.
|
||||
load_agents_api_config_from_dict(config_data.get("agents_api") or {})
|
||||
|
||||
# Load subagents config if present
|
||||
if "subagents" in config_data:
|
||||
load_subagents_config_from_dict(config_data["subagents"])
|
||||
|
||||
@ -6,6 +6,7 @@ from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from deerflow.config.agents_api_config import get_agents_api_config
|
||||
from deerflow.config.app_config import get_app_config, reset_app_config
|
||||
|
||||
|
||||
@ -28,6 +29,30 @@ def _write_config(path: Path, *, model_name: str, supports_thinking: bool) -> No
|
||||
)
|
||||
|
||||
|
||||
def _write_config_with_agents_api(
|
||||
path: Path,
|
||||
*,
|
||||
model_name: str,
|
||||
supports_thinking: bool,
|
||||
agents_api: dict | None = None,
|
||||
) -> None:
|
||||
config = {
|
||||
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
||||
"models": [
|
||||
{
|
||||
"name": model_name,
|
||||
"use": "langchain_openai:ChatOpenAI",
|
||||
"model": "gpt-test",
|
||||
"supports_thinking": supports_thinking,
|
||||
}
|
||||
],
|
||||
}
|
||||
if agents_api is not None:
|
||||
config["agents_api"] = agents_api
|
||||
|
||||
path.write_text(yaml.safe_dump(config), encoding="utf-8")
|
||||
|
||||
|
||||
def _write_extensions_config(path: Path) -> None:
|
||||
path.write_text(json.dumps({"mcpServers": {}, "skills": {}}), encoding="utf-8")
|
||||
|
||||
@ -79,3 +104,38 @@ def test_get_app_config_reloads_when_config_path_changes(tmp_path, monkeypatch):
|
||||
assert second is not first
|
||||
finally:
|
||||
reset_app_config()
|
||||
|
||||
|
||||
def test_get_app_config_resets_agents_api_config_when_section_removed(tmp_path, monkeypatch):
|
||||
config_path = tmp_path / "config.yaml"
|
||||
extensions_path = tmp_path / "extensions_config.json"
|
||||
_write_extensions_config(extensions_path)
|
||||
_write_config_with_agents_api(
|
||||
config_path,
|
||||
model_name="first-model",
|
||||
supports_thinking=False,
|
||||
agents_api={"enabled": True},
|
||||
)
|
||||
|
||||
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
||||
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
||||
reset_app_config()
|
||||
|
||||
try:
|
||||
initial = get_app_config()
|
||||
assert initial.models[0].name == "first-model"
|
||||
assert get_agents_api_config().enabled is True
|
||||
|
||||
_write_config_with_agents_api(
|
||||
config_path,
|
||||
model_name="first-model",
|
||||
supports_thinking=False,
|
||||
)
|
||||
next_mtime = config_path.stat().st_mtime + 5
|
||||
os.utime(config_path, (next_mtime, next_mtime))
|
||||
|
||||
reloaded = get_app_config()
|
||||
assert reloaded is not initial
|
||||
assert get_agents_api_config().enabled is False
|
||||
finally:
|
||||
reset_app_config()
|
||||
|
||||
@ -9,6 +9,8 @@ import pytest
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from deerflow.config.agents_api_config import AgentsApiConfig, get_agents_api_config, set_agents_api_config
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -387,13 +389,38 @@ def _make_test_app(tmp_path: Path):
|
||||
@pytest.fixture()
|
||||
def agent_client(tmp_path):
|
||||
"""TestClient with agents router, using tmp_path as base_dir."""
|
||||
paths_instance = _make_paths(tmp_path)
|
||||
import app.gateway.routers.agents as agents_router
|
||||
|
||||
with patch("deerflow.config.agents_config.get_paths", return_value=paths_instance), patch("app.gateway.routers.agents.get_paths", return_value=paths_instance):
|
||||
app = _make_test_app(tmp_path)
|
||||
with TestClient(app) as client:
|
||||
client._tmp_path = tmp_path # type: ignore[attr-defined]
|
||||
yield client
|
||||
paths_instance = _make_paths(tmp_path)
|
||||
previous_config = AgentsApiConfig(**get_agents_api_config().model_dump())
|
||||
|
||||
with patch("deerflow.config.agents_config.get_paths", return_value=paths_instance), patch.object(agents_router, "get_paths", return_value=paths_instance):
|
||||
set_agents_api_config(AgentsApiConfig(enabled=True))
|
||||
try:
|
||||
app = _make_test_app(tmp_path)
|
||||
with TestClient(app) as client:
|
||||
client._tmp_path = tmp_path # type: ignore[attr-defined]
|
||||
yield client
|
||||
finally:
|
||||
set_agents_api_config(previous_config)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def disabled_agent_client(tmp_path):
|
||||
"""TestClient with agents router while the management API is disabled."""
|
||||
import app.gateway.routers.agents as agents_router
|
||||
|
||||
paths_instance = _make_paths(tmp_path)
|
||||
previous_config = AgentsApiConfig(**get_agents_api_config().model_dump())
|
||||
|
||||
with patch("deerflow.config.agents_config.get_paths", return_value=paths_instance), patch.object(agents_router, "get_paths", return_value=paths_instance):
|
||||
set_agents_api_config(AgentsApiConfig(enabled=False))
|
||||
try:
|
||||
app = _make_test_app(tmp_path)
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
finally:
|
||||
set_agents_api_config(previous_config)
|
||||
|
||||
|
||||
class TestAgentsAPI:
|
||||
@ -559,3 +586,37 @@ class TestUserProfileAPI:
|
||||
response = agent_client.put("/api/user-profile", json={"content": ""})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["content"] is None
|
||||
|
||||
|
||||
class TestAgentsApiDisabled:
|
||||
def test_agents_list_returns_403(self, disabled_agent_client):
|
||||
response = disabled_agent_client.get("/api/agents")
|
||||
assert response.status_code == 403
|
||||
assert "agents_api.enabled=true" in response.json()["detail"]
|
||||
|
||||
def test_agent_get_returns_403(self, disabled_agent_client):
|
||||
response = disabled_agent_client.get("/api/agents/example-agent")
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_agent_name_check_returns_403(self, disabled_agent_client):
|
||||
response = disabled_agent_client.get("/api/agents/check", params={"name": "example-agent"})
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_agent_create_returns_403(self, disabled_agent_client):
|
||||
response = disabled_agent_client.post("/api/agents", json={"name": "example-agent", "soul": "blocked"})
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_agent_update_returns_403(self, disabled_agent_client):
|
||||
response = disabled_agent_client.put("/api/agents/example-agent", json={"description": "blocked"})
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_agent_delete_returns_403(self, disabled_agent_client):
|
||||
response = disabled_agent_client.delete("/api/agents/example-agent")
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_user_profile_routes_return_403(self, disabled_agent_client):
|
||||
get_response = disabled_agent_client.get("/api/user-profile")
|
||||
put_response = disabled_agent_client.put("/api/user-profile", json={"content": "blocked"})
|
||||
|
||||
assert get_response.status_code == 403
|
||||
assert put_response.status_code == 403
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
# ============================================================================
|
||||
# Bump this number when the config schema changes.
|
||||
# Run `make config-upgrade` to merge new fields into your local config.yaml.
|
||||
config_version: 6
|
||||
config_version: 7
|
||||
|
||||
# ============================================================================
|
||||
# Logging
|
||||
@ -708,6 +708,14 @@ memory:
|
||||
injection_enabled: true # Whether to inject memory into system prompt
|
||||
max_injection_tokens: 2000 # Maximum tokens for memory injection
|
||||
|
||||
# ============================================================================
|
||||
# Custom Agent Management API
|
||||
# ============================================================================
|
||||
# Controls whether the HTTP gateway exposes custom-agent SOUL/USER.md management.
|
||||
# Keep this disabled unless the gateway is behind a trusted authenticated admin boundary.
|
||||
agents_api:
|
||||
enabled: false
|
||||
|
||||
# ============================================================================
|
||||
# Skill Self-Evolution Configuration
|
||||
# ============================================================================
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user