mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-21 02:05:45 +00:00
fix(skills): add security_fail_closed option for moderation model outages (#4297)
* fix(skills): add security_fail_closed option for moderation model outages When the skill security moderation model call fails, scan_skill_content previously blocked ALL content (executable and non-executable), which turns a moderation-model outage into a denial of service for skill writes. Add a skill_evolution.security_fail_closed option (default True, preserving current behavior). When set to False, non-executable content is allowed with a warn decision during an outage while executable content is still blocked. Closes #3021 * fix(config): bump config_version to 27 and format skill_evolution config Address review feedback on #4297: - Bump config_version 26 -> 27 so existing installs are flagged outdated and pick up skill_evolution.security_fail_closed via make config-upgrade. - Apply ruff format to skill_evolution_config.py to satisfy the backend formatting gate. - Add config-version/upgrade regression tests covering the v26 outdated warning and merging security_fail_closed without changing user values. * fix(helm): bump chart config_version to 27 to match config.example.yaml Keeps deploy/helm/deer-flow/values.yaml and its README example in sync with the config schema bump, satisfying scripts/check_config_version.sh (validate-chart CI). * fix(skills): surface fail-open security scan in logs Address @willem-bd review feedback on #4297: - Log an operator-visible warning when the moderation model is unavailable and fail-open lets non-executable skill content through as a warn, so a skipped scan is no longer silent. - Reword the model-call-failed log so it stays accurate under both fail-closed and fail-open policy instead of always claiming a "conservative fallback". - Add a regression test asserting the fail-open warn path emits the warning log.
This commit is contained in:
@@ -12,3 +12,7 @@ class SkillEvolutionConfig(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Optional model name for skill security moderation. Defaults to the primary chat model.",
|
description="Optional model name for skill security moderation. Defaults to the primary chat model.",
|
||||||
)
|
)
|
||||||
|
security_fail_closed: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description=("When the moderation model is unavailable, block skill writes if True (fail-closed). If False, non-executable content is allowed with a warning while executable content is still blocked."),
|
||||||
|
)
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ class ScanResult:
|
|||||||
reason: str
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_fail_closed(app_config: AppConfig | None) -> bool:
|
||||||
|
"""Resolve the fail-closed policy, defaulting to True if config is unavailable."""
|
||||||
|
try:
|
||||||
|
config = app_config or get_app_config()
|
||||||
|
return bool(getattr(config.skill_evolution, "security_fail_closed", True))
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _extract_json_object(raw: str) -> dict | None:
|
def _extract_json_object(raw: str) -> dict | None:
|
||||||
raw = raw.strip()
|
raw = raw.strip()
|
||||||
|
|
||||||
@@ -120,10 +129,13 @@ async def scan_skill_content(
|
|||||||
return ScanResult(decision, str(parsed.get("reason") or "No reason provided."))
|
return ScanResult(decision, str(parsed.get("reason") or "No reason provided."))
|
||||||
logger.warning("Security scan produced unparseable output: %s", raw[:200])
|
logger.warning("Security scan produced unparseable output: %s", raw[:200])
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Skill security scan model call failed; using conservative fallback", exc_info=True)
|
logger.warning("Skill security scan model call failed; applying configured fail-closed/fail-open policy", exc_info=True)
|
||||||
|
|
||||||
if model_responded:
|
if model_responded:
|
||||||
return ScanResult("block", "Security scan produced unparseable output; manual review required.")
|
return ScanResult("block", "Security scan produced unparseable output; manual review required.")
|
||||||
if executable:
|
if executable:
|
||||||
return ScanResult("block", "Security scan unavailable for executable content; manual review required.")
|
return ScanResult("block", "Security scan unavailable for executable content; manual review required.")
|
||||||
return ScanResult("block", "Security scan unavailable for skill content; manual review required.")
|
if _resolve_fail_closed(app_config):
|
||||||
|
return ScanResult("block", "Security scan unavailable for skill content; manual review required.")
|
||||||
|
logger.warning("Security scan unavailable; failing open for non-executable skill content at %s (manual review recommended)", location)
|
||||||
|
return ScanResult("warn", "Security scan unavailable for non-executable skill content; manual review recommended.")
|
||||||
|
|||||||
@@ -123,3 +123,68 @@ def test_newer_user_version_no_warning(caplog):
|
|||||||
config_path,
|
config_path,
|
||||||
)
|
)
|
||||||
assert "outdated" not in caplog.text
|
assert "outdated" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def _load_repo_example() -> dict:
|
||||||
|
"""Load the real repo config.example.yaml (first-run template)."""
|
||||||
|
example_path = Path(__file__).resolve().parents[2] / "config.example.yaml"
|
||||||
|
with open(example_path, encoding="utf-8") as f:
|
||||||
|
return yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_missing(target: dict, source: dict) -> None:
|
||||||
|
"""Add-missing-keys-only recursive merge mirroring scripts/config-upgrade.sh."""
|
||||||
|
for key, value in source.items():
|
||||||
|
if key not in target:
|
||||||
|
import copy
|
||||||
|
|
||||||
|
target[key] = copy.deepcopy(value)
|
||||||
|
elif isinstance(value, dict) and isinstance(target[key], dict):
|
||||||
|
_merge_missing(target[key], value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_security_fail_closed_bumped_config_version():
|
||||||
|
"""The example must ship security_fail_closed under a version > 26 so v26 configs upgrade."""
|
||||||
|
example = _load_repo_example()
|
||||||
|
assert example.get("config_version", 0) >= 27
|
||||||
|
assert example["skill_evolution"]["security_fail_closed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_26_config_reported_outdated_against_example(caplog):
|
||||||
|
"""A version-26 user config is flagged outdated against the real example version."""
|
||||||
|
example = _load_repo_example()
|
||||||
|
example_version = example["config_version"]
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
config_path = _make_config_files(
|
||||||
|
Path(tmpdir),
|
||||||
|
user_config={"config_version": 26},
|
||||||
|
example_config=example,
|
||||||
|
)
|
||||||
|
with caplog.at_level(logging.WARNING, logger="deerflow.config.app_config"):
|
||||||
|
AppConfig._check_config_version({"config_version": 26}, config_path)
|
||||||
|
assert "outdated" in caplog.text
|
||||||
|
assert "version 26" in caplog.text
|
||||||
|
assert f"version is {example_version}" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_upgrade_adds_security_fail_closed_preserving_user_values():
|
||||||
|
"""config-upgrade merges security_fail_closed: true without touching existing skill_evolution values."""
|
||||||
|
example = _load_repo_example()
|
||||||
|
# A version-26 user who customized skill_evolution but predates the new field.
|
||||||
|
user = {
|
||||||
|
"config_version": 26,
|
||||||
|
"skill_evolution": {
|
||||||
|
"enabled": True,
|
||||||
|
"moderation_model_name": "custom-moderation-model",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_merge_missing(user, example)
|
||||||
|
user["config_version"] = example["config_version"]
|
||||||
|
|
||||||
|
# New persisted field is merged in with the example's fail-closed default.
|
||||||
|
assert user["skill_evolution"]["security_fail_closed"] is True
|
||||||
|
# The user's existing skill_evolution values are preserved unchanged.
|
||||||
|
assert user["skill_evolution"]["enabled"] is True
|
||||||
|
assert user["skill_evolution"]["moderation_model_name"] == "custom-moderation-model"
|
||||||
|
assert user["config_version"] == example["config_version"]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -139,3 +140,50 @@ async def test_scan_distinguishes_unparseable_executable(monkeypatch):
|
|||||||
# Even for executable content, unparseable uses the unparseable message
|
# Even for executable content, unparseable uses the unparseable message
|
||||||
assert result.decision == "block"
|
assert result.decision == "block"
|
||||||
assert "unparseable" in result.reason
|
assert "unparseable" in result.reason
|
||||||
|
|
||||||
|
|
||||||
|
def _make_unavailable_env(monkeypatch, *, security_fail_closed):
|
||||||
|
config = SimpleNamespace(
|
||||||
|
skill_evolution=SimpleNamespace(
|
||||||
|
moderation_model_name=None,
|
||||||
|
security_fail_closed=security_fail_closed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("deerflow.skills.security_scanner.get_app_config", lambda: config)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"deerflow.skills.security_scanner.create_chat_model",
|
||||||
|
lambda **kwargs: (_ for _ in ()).throw(RuntimeError("boom")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_fail_open_allows_non_executable_when_model_unavailable(monkeypatch):
|
||||||
|
_make_unavailable_env(monkeypatch, security_fail_closed=False)
|
||||||
|
result = await scan_skill_content(SKILL_CONTENT, executable=False)
|
||||||
|
assert result.decision == "warn"
|
||||||
|
assert "unavailable" in result.reason
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_fail_open_still_blocks_executable_when_model_unavailable(monkeypatch):
|
||||||
|
_make_unavailable_env(monkeypatch, security_fail_closed=False)
|
||||||
|
result = await scan_skill_content(SKILL_CONTENT, executable=True)
|
||||||
|
assert result.decision == "block"
|
||||||
|
assert "executable" in result.reason
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_fail_closed_blocks_non_executable_when_model_unavailable(monkeypatch):
|
||||||
|
_make_unavailable_env(monkeypatch, security_fail_closed=True)
|
||||||
|
result = await scan_skill_content(SKILL_CONTENT, executable=False)
|
||||||
|
assert result.decision == "block"
|
||||||
|
assert "unavailable" in result.reason
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_fail_open_logs_operator_visible_warning(monkeypatch, caplog):
|
||||||
|
_make_unavailable_env(monkeypatch, security_fail_closed=False)
|
||||||
|
with caplog.at_level(logging.WARNING, logger="deerflow.skills.security_scanner"):
|
||||||
|
result = await scan_skill_content(SKILL_CONTENT, executable=False)
|
||||||
|
assert result.decision == "warn"
|
||||||
|
assert "failing open" in caplog.text
|
||||||
|
|||||||
+2
-1
@@ -15,7 +15,7 @@
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Bump this number when the config schema changes.
|
# Bump this number when the config schema changes.
|
||||||
# Run `make config-upgrade` to merge new fields into your local config.yaml.
|
# Run `make config-upgrade` to merge new fields into your local config.yaml.
|
||||||
config_version: 26
|
config_version: 27
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Logging
|
# Logging
|
||||||
@@ -1577,6 +1577,7 @@ agents_api:
|
|||||||
skill_evolution:
|
skill_evolution:
|
||||||
enabled: false # Set to true to allow agent-managed writes under skills/custom
|
enabled: false # Set to true to allow agent-managed writes under skills/custom
|
||||||
moderation_model_name: null # Model for LLM-based security scanning (null = use default model)
|
moderation_model_name: null # Model for LLM-based security scanning (null = use default model)
|
||||||
|
security_fail_closed: true # Moderation model unavailable: true blocks all writes; false allows non-executable content with a warning (executable is always blocked)
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Checkpointer Configuration (DEPRECATED — use `database` instead)
|
# Checkpointer Configuration (DEPRECATED — use `database` instead)
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ they resolve from the `secrets` map):
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
config: |
|
config: |
|
||||||
config_version: 26
|
config_version: 27
|
||||||
models:
|
models:
|
||||||
- name: gpt-4
|
- name: gpt-4
|
||||||
use: langchain_openai:ChatOpenAI
|
use: langchain_openai:ChatOpenAI
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ ingress:
|
|||||||
# -- DeerFlow config.yaml content. Secrets MUST stay as $VAR references — never
|
# -- DeerFlow config.yaml content. Secrets MUST stay as $VAR references — never
|
||||||
# inline literal secret values here. The default enables provisioner sandbox.
|
# inline literal secret values here. The default enables provisioner sandbox.
|
||||||
config: |
|
config: |
|
||||||
config_version: 26
|
config_version: 27
|
||||||
log_level: info
|
log_level: info
|
||||||
|
|
||||||
models: []
|
models: []
|
||||||
|
|||||||
Reference in New Issue
Block a user