fix(agents): prevent lost subagent findings and warn on unfiled reports

In multi-agent pentests (e.g. Issue #1439 against DVWA), subagents frequently
discover exploitable vulnerabilities and supply them as narrative strings in
agent_finish(findings=[...]) without first calling create_vulnerability_report.
Because parent agents strictly rely on filed_report_ids from authoritative state
and avoid refiling from narrative text, these findings are silently dropped,
causing scans of vulnerable targets to complete with 0 findings.

1. In child_initial_input, explicitly instruct subagents to file confirmed
   vulnerabilities via create_vulnerability_report before calling agent_finish,
   clarifying that passing findings to agent_finish does not persist reports.
2. In agent_finish, when findings is non-empty but filed_report_ids is empty,
   attach an explicit warning to the tool response payload and the rendered
   completion report delivered to the parent inbox.
3. Add regression tests in test_agent_graph_coordination.py and test_inputs.py.
This commit is contained in:
MuchoRio 2026-10-10 19:48:34 +07:00
parent 62b496430d
commit b60224b662
4 changed files with 69 additions and 13 deletions

View file

@ -364,8 +364,10 @@ def child_initial_input(
)
parts.append(
f"You are agent {name} ({child_id}); your parent is {parent_id}. "
"Maintain your own identity. Call agent_finish when your task "
"is complete.",
"Maintain your own identity. File any confirmed vulnerabilities using "
"create_vulnerability_report (or create_dependency_report) BEFORE calling "
"agent_finish — passing findings to agent_finish does not file reports. "
"Call agent_finish when your task is complete.",
)
parts.append(task)
return [{"role": "user", "content": "\n\n".join(parts)}]

View file

@ -101,6 +101,12 @@ def _render_completion_report(
lines.extend(_render_filed_report(r) for r in filed_reports)
else:
lines.append("- (none)")
if findings:
lines.append(
" [WARNING: This agent reported narrative findings above but filed 0 "
"authoritative vulnerability reports. Unreported findings are not included "
"in scan results or SARIF unless filed via create_vulnerability_report.]"
)
lines.append("")
lines.append("Open items (unresolved, need follow-up):")
if open_items:
@ -693,6 +699,7 @@ async def agent_finish(
filed_reports = _filed_reports_by(me)
filed_report_ids = [str(r.get("id")) for r in filed_reports]
unfiled_findings = bool((findings or []) and not filed_report_ids)
parent_notified = False
if report_to_parent and await coordinator.claim_parent_notice(me):
@ -718,6 +725,7 @@ async def agent_finish(
"type": "completion",
"priority": "high",
"filed_report_ids": filed_report_ids,
"unfiled_findings": unfiled_findings,
},
)
parent_notified = True
@ -735,19 +743,33 @@ async def agent_finish(
len(filed_report_ids),
parent_notified,
)
if unfiled_findings:
logger.warning(
"agent_finish: %s reported %d narrative finding(s) but filed 0 vulnerability reports",
me,
len(findings or []),
)
response_payload: dict[str, Any] = {
"success": True,
"agent_completed": True,
"parent_notified": parent_notified,
"agent_id": me,
"summary": result_summary,
"filed_report_ids": filed_report_ids,
"findings_count": len(findings or []),
"open_items_count": len(open_items or []),
"has_recommendations": bool(final_recommendations),
}
if unfiled_findings:
response_payload["warning"] = (
f"Reported {len(findings or [])} finding(s) but filed 0 vulnerability reports. "
"Narrative findings in agent_finish do not register vulnerabilities in the scan "
"report. Verified findings must be filed via create_vulnerability_report."
)
return json.dumps(
{
"success": True,
"agent_completed": True,
"parent_notified": parent_notified,
"agent_id": me,
"summary": result_summary,
"filed_report_ids": filed_report_ids,
"findings_count": len(findings or []),
"open_items_count": len(open_items or []),
"has_recommendations": bool(final_recommendations),
},
response_payload,
ensure_ascii=False,
default=str,
)

View file

@ -256,3 +256,33 @@ async def test_agent_finish_without_report_state_still_completes() -> None:
assert result["success"] is True
assert result["filed_report_ids"] == []
@pytest.mark.asyncio
@pytest.mark.usefixtures("report_state")
async def test_agent_finish_warns_when_findings_reported_without_filing() -> None:
coordinator = await _graph(interactive=False)
result = await _call(
agent_finish,
coordinator,
"child",
{
"result_summary": "discovered injection",
"findings": ["SQL injection on /login.php"],
},
parent_id="root",
)
assert result["success"] is True
assert result["filed_report_ids"] == []
assert "warning" in result
assert "Reported 1 finding(s) but filed 0 vulnerability reports" in result["warning"]
delivered = coordinator.runtimes["root"].mailbox
assert len(delivered) == 1
assert delivered[0]["unfiled_findings"] is True
body = delivered[0]["content"]
assert "authoritative vulnerability reports" in body
assert "create_vulnerability_report" in body

View file

@ -35,6 +35,7 @@ def test_child_initial_input_single_message_without_history() -> None:
content = result[0]["content"]
assert "agent scout (agent-2)" in content
assert "Audit the login flow." in content
assert "create_vulnerability_report" in content
assert "Inherited context" not in content
@ -49,6 +50,7 @@ def test_child_initial_input_single_message_with_history() -> None:
assert "previous work" in content
assert "agent scout (agent-2)" in content
assert "Audit the login flow." in content
assert "create_vulnerability_report" in content
@pytest.mark.parametrize(