fix(skills): don't attach model tracing to the in-graph skill security scan (#4252)

* fix(skills): don't attach model tracing to the in-graph skill security scan

* fix(skills): pass attach_tracing explicitly from the in-graph scan call site

Follow the tracing INVARIANT's own convention rather than detecting the call
context: scan_skill_content takes an attach_tracing flag, and _scan_or_raise --
the single in-graph choke point -- passes False. Standalone callers (Gateway
skill routes, installer) keep the default True.

The INVARIANT list named four sites and asks that new in-graph calls be added
to it; record this fifth one so a future audit of that list finds it.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Aari 2026-07-20 08:18:23 +08:00 committed by GitHub
parent 39cc26ddfc
commit cd34a1a504
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 102 additions and 6 deletions

View File

@ -13,8 +13,12 @@ middleware reachable from this graph (e.g. ``TitleMiddleware``) — MUST pass
Forgetting that flag emits duplicate spans (one rooted at the graph, one at Forgetting that flag emits duplicate spans (one rooted at the graph, one at
the model) AND prevents the Langfuse handler's ``propagate_attributes`` the model) AND prevents the Langfuse handler's ``propagate_attributes``
path from firing, so ``session_id`` / ``user_id`` never reach the trace. path from firing, so ``session_id`` / ``user_id`` never reach the trace.
The four current sites are: bootstrap agent, default agent, summarization The five current sites are: bootstrap agent, default agent, summarization
middleware, and the async path inside ``TitleMiddleware``. Any new in-graph middleware, the async path inside ``TitleMiddleware``, and the skill security
scanner reached from the ``skill_manage`` tool (``skills/security_scanner.py``'s
``scan_skill_content``, which is dual-use: ``_scan_or_raise`` in
``tools/skill_manage_tool.py`` is the in-graph choke point and passes the flag,
while its standalone callers keep the default). Any new in-graph
``create_chat_model`` call must add to this list and pass the flag. ``create_chat_model`` call must add to this list and pass the flag.
""" """

View File

@ -96,8 +96,19 @@ async def scan_skill_content(
location: str = SKILL_MD_FILE, location: str = SKILL_MD_FILE,
app_config: AppConfig | None = None, app_config: AppConfig | None = None,
static_findings: list[dict[str, Any]] | None = None, static_findings: list[dict[str, Any]] | None = None,
attach_tracing: bool = True,
) -> ScanResult: ) -> ScanResult:
"""Screen skill content before it is written to disk.""" """Screen skill content before it is written to disk.
``attach_tracing`` follows the tracing INVARIANT in
``agents/lead_agent/agent.py``: in-graph callers must pass ``False`` because
the graph root already attached the callbacks, and attaching again at the
model emits duplicate spans *and* blocks the Langfuse handler's
``propagate_attributes`` path. This function is dual-use, so the flag is the
caller's to set — the in-graph choke point is ``_scan_or_raise`` in
``tools/skill_manage_tool.py``. Standalone callers (Gateway skill routes,
``skills/installer.py``) have no root to inherit from and keep the default.
"""
rubric = ( rubric = (
"You are a security reviewer for AI agent skills. " "You are a security reviewer for AI agent skills. "
"Classify the content as allow, warn, or block. " "Classify the content as allow, warn, or block. "
@ -112,7 +123,8 @@ async def scan_skill_content(
try: try:
config = app_config or get_app_config() config = app_config or get_app_config()
model_name = config.skill_evolution.moderation_model_name model_name = config.skill_evolution.moderation_model_name
model = create_chat_model(name=model_name, thinking_enabled=False, app_config=config) if model_name else create_chat_model(thinking_enabled=False, app_config=config) model_kwargs = {"thinking_enabled": False, "app_config": config, "attach_tracing": attach_tracing}
model = create_chat_model(name=model_name, **model_kwargs) if model_name else create_chat_model(**model_kwargs)
response = await model.ainvoke( response = await model.ainvoke(
[ [
{"role": "system", "content": rubric}, {"role": "system", "content": rubric},

View File

@ -64,7 +64,9 @@ def _history_record(*, action: str, file_path: str, prev_content: str | None, ne
async def _scan_or_raise(content: str, *, executable: bool, location: str, static_findings: list[StaticFinding] | None = None) -> dict[str, Any]: async def _scan_or_raise(content: str, *, executable: bool, location: str, static_findings: list[StaticFinding] | None = None) -> dict[str, Any]:
result = await scan_skill_content(content, executable=executable, location=location, static_findings=static_findings or []) # In-graph: the graph root already attached tracing (see the INVARIANT in
# agents/lead_agent/agent.py), so the scan model must not attach it again.
result = await scan_skill_content(content, executable=executable, location=location, static_findings=static_findings or [], attach_tracing=False)
if result.decision == "block": if result.decision == "block":
raise ValueError(f"Security scan blocked the write: {result.reason}") raise ValueError(f"Security scan blocked the write: {result.reason}")
if executable and result.decision != "allow": if executable and result.decision != "allow":

View File

@ -17,8 +17,13 @@ def _make_env(monkeypatch, response_content):
return fake_response return fake_response
model = FakeModel() model = FakeModel()
def _fake_create_chat_model(**kwargs):
model.create_kwargs = kwargs
return model
monkeypatch.setattr("deerflow.skills.security_scanner.get_app_config", lambda: config) monkeypatch.setattr("deerflow.skills.security_scanner.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.skills.security_scanner.create_chat_model", lambda **kwargs: model) monkeypatch.setattr("deerflow.skills.security_scanner.create_chat_model", _fake_create_chat_model)
return model return model
@ -142,6 +147,39 @@ async def test_scan_distinguishes_unparseable_executable(monkeypatch):
assert "unparseable" in result.reason assert "unparseable" in result.reason
# --- tracing wiring: in-graph vs standalone (see the INVARIANT in
# packages/harness/deerflow/agents/lead_agent/agent.py and the Tracing System
# section of backend/AGENTS.md) ---
@pytest.mark.anyio
async def test_scan_skill_content_forwards_attach_tracing_to_the_model(monkeypatch):
"""In-graph callers pass ``attach_tracing=False``; it must reach the factory.
The graph root already attached the callbacks, so attaching again at the model
emits duplicate spans and blocks the Langfuse handler's ``propagate_attributes``
path, meaning session_id/user_id never land on the trace.
"""
model = _make_env(monkeypatch, '{"decision":"allow","reason":"ok"}')
result = await scan_skill_content(SKILL_CONTENT, executable=False, attach_tracing=False)
assert result.decision == "allow"
assert model.create_kwargs["attach_tracing"] is False
@pytest.mark.anyio
async def test_scan_skill_content_attaches_model_tracing_by_default(monkeypatch):
"""Standalone callers (Gateway skill routes, installer) have no graph root to
inherit from, so the default keeps model-level attachment.
Anchors the other direction of the change: narrowing the fix into an
unconditional ``attach_tracing=False`` would silently drop their spans.
"""
model = _make_env(monkeypatch, '{"decision":"allow","reason":"ok"}')
result = await scan_skill_content(SKILL_CONTENT, executable=False)
assert result.decision == "allow"
assert model.create_kwargs["attach_tracing"] is True
def _make_unavailable_env(monkeypatch, *, security_fail_closed): def _make_unavailable_env(monkeypatch, *, security_fail_closed):
config = SimpleNamespace( config = SimpleNamespace(
skill_evolution=SimpleNamespace( skill_evolution=SimpleNamespace(

View File

@ -340,3 +340,43 @@ def test_skill_manage_per_user_isolation(monkeypatch, tmp_path):
# No cross-contamination # No cross-contamination
assert not (tmp_path / "users" / "alice" / "skills" / "custom" / "bob-skill").exists() assert not (tmp_path / "users" / "alice" / "skills" / "custom" / "bob-skill").exists()
assert not (tmp_path / "users" / "bob" / "skills" / "custom" / "alice-skill").exists() assert not (tmp_path / "users" / "bob" / "skills" / "custom" / "alice-skill").exists()
# --- tracing wiring: the in-graph choke point (see the INVARIANT in
# packages/harness/deerflow/agents/lead_agent/agent.py) ---
def test_scan_or_raise_does_not_attach_model_tracing(monkeypatch, tmp_path):
"""``_scan_or_raise`` is the in-graph choke point for the skill security scan.
The graph root already attached the tracing callbacks, so the scan model must
not attach them again: double-attaching emits duplicate spans and blocks the
Langfuse handler's ``propagate_attributes`` path, so session_id/user_id never
reach the trace. Drives the real ``scan_skill_content`` rather than stubbing it,
so the flag is pinned all the way to the model factory.
"""
config = _make_config(tmp_path / "skills")
monkeypatch.setattr("deerflow.skills.security_scanner.get_app_config", lambda: config)
create_kwargs = {}
class FakeModel:
async def ainvoke(self, *args, **kwargs):
return SimpleNamespace(content='{"decision":"allow","reason":"ok"}')
def _fake_create_chat_model(**kwargs):
create_kwargs.update(kwargs)
return FakeModel()
monkeypatch.setattr("deerflow.skills.security_scanner.create_chat_model", _fake_create_chat_model)
result = anyio.run(
lambda: skill_manage_module._scan_or_raise(
_skill_content("demo-skill"),
executable=False,
location="demo-skill/SKILL.md",
)
)
assert result["decision"] == "allow"
assert create_kwargs["attach_tracing"] is False