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:
Hanchen Qiu
2026-07-19 18:56:03 +08:00
committed by GitHub
parent 271a921baf
commit d2f8f61e3a
7 changed files with 135 additions and 5 deletions
@@ -12,3 +12,7 @@ class SkillEvolutionConfig(BaseModel):
default=None,
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
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:
raw = raw.strip()
@@ -120,10 +129,13 @@ async def scan_skill_content(
return ScanResult(decision, str(parsed.get("reason") or "No reason provided."))
logger.warning("Security scan produced unparseable output: %s", raw[:200])
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:
return ScanResult("block", "Security scan produced unparseable output; manual review required.")
if executable:
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.")
+65
View File
@@ -123,3 +123,68 @@ def test_newer_user_version_no_warning(caplog):
config_path,
)
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"]
+48
View File
@@ -1,3 +1,4 @@
import logging
from types import SimpleNamespace
import pytest
@@ -139,3 +140,50 @@ async def test_scan_distinguishes_unparseable_executable(monkeypatch):
# Even for executable content, unparseable uses the unparseable message
assert result.decision == "block"
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
View File
@@ -15,7 +15,7 @@
# ============================================================================
# Bump this number when the config schema changes.
# Run `make config-upgrade` to merge new fields into your local config.yaml.
config_version: 26
config_version: 27
# ============================================================================
# Logging
@@ -1577,6 +1577,7 @@ agents_api:
skill_evolution:
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)
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)
+1 -1
View File
@@ -124,7 +124,7 @@ they resolve from the `secrets` map):
```yaml
config: |
config_version: 26
config_version: 27
models:
- name: gpt-4
use: langchain_openai:ChatOpenAI
+1 -1
View File
@@ -240,7 +240,7 @@ ingress:
# -- DeerFlow config.yaml content. Secrets MUST stay as $VAR references — never
# inline literal secret values here. The default enables provisioner sandbox.
config: |
config_version: 26
config_version: 27
log_level: info
models: []