diff --git a/strix/interface/tui/runtime.py b/strix/interface/tui/runtime.py index d398e5ab..a20960ce 100644 --- a/strix/interface/tui/runtime.py +++ b/strix/interface/tui/runtime.py @@ -49,6 +49,11 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +def _revision_count(report: dict[str, Any]) -> int: + history = report.get("update_history") + return len(history) if isinstance(history, list) else 0 + + class GoTuiPreActivationError(RuntimeError): """A sidecar failure raised before the Go TUI activates.""" @@ -339,7 +344,9 @@ class GoTuiRuntime: if self.report_state is not None: usage = dict(self.report_state.get_total_llm_usage()) vulnerabilities = [ - report.get("id", index) if isinstance(report, dict) else index + (report.get("id", index), _revision_count(report)) + if isinstance(report, dict) + else index for index, report in enumerate(self.report_state.vulnerability_reports) ] return json.dumps( diff --git a/strix/report/state.py b/strix/report/state.py index 6a082371..ac15db89 100644 --- a/strix/report/state.py +++ b/strix/report/state.py @@ -73,6 +73,7 @@ UPDATABLE_REPORT_FIELDS = frozenset( "cve", "cwe", "code_locations", + "http_exchange_ids", "fix_verification", "fix_pr_body", } @@ -333,6 +334,7 @@ class ReportState: cve: str | None = None, cwe: str | None = None, code_locations: list[dict[str, Any]] | None = None, + http_exchange_ids: list[str] | None = None, fix_verification: str | None = None, fix_pr_body: str | None = None, finding_class: str | None = None, @@ -391,6 +393,8 @@ class ReportState: report["cwe"] = cwe.strip() if code_locations: report["code_locations"] = code_locations + if http_exchange_ids: + report["http_exchange_ids"] = http_exchange_ids if fix_verification: report["fix_verification"] = fix_verification.strip() if fix_pr_body: @@ -403,14 +407,14 @@ class ReportState: if agent_name: report["agent_name"] = agent_name + if self.vulnerability_found_callback: + self.vulnerability_found_callback(report) + self.vulnerability_reports.append(report) logger.info(f"Added vulnerability report: {report_id} - {title}") posthog.finding(severity, cwe=cwe, is_cve=bool(cve)) scarf.finding(severity, cwe=cwe, is_cve=bool(cve)) - if self.vulnerability_found_callback: - self.vulnerability_found_callback(report) - self.save_run_data() return report_id @@ -486,11 +490,18 @@ class ReportState: ) history.append(entry) - report.update(changed) + revised = {**report, **changed} for dependent in superseded: - report.pop(dependent, None) - report["update_history"] = history - report["updated_at"] = entry["timestamp"] + revised.pop(dependent, None) + revised["update_history"] = history + revised["updated_at"] = entry["timestamp"] + + # Persistence must accept the revision before local state changes. A + # failed callback leaves the old evidence intact and the update retryable. + if self.vulnerability_updated_callback: + self.vulnerability_updated_callback(revised) + report.clear() + report.update(revised) # The markdown on disk still shows the superseded evidence, so let the # writer re-render it. @@ -502,9 +513,6 @@ class ReportState: ", ".join(entry["fields"]) or "no field replaced", ) - if self.vulnerability_updated_callback: - self.vulnerability_updated_callback(report) - self.save_run_data() return report diff --git a/strix/tools/proxy/tools.py b/strix/tools/proxy/tools.py index e465578a..0dc2a03c 100644 --- a/strix/tools/proxy/tools.py +++ b/strix/tools/proxy/tools.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import dataclasses +import functools import json import logging import re @@ -67,6 +68,31 @@ async def _call[T](client: Client, fn: Callable[[Client], Awaitable[T]]) -> T: return await fn(client) +async def existing_request_ids( + ctx: RunContextWrapper, + request_ids: list[str], +) -> set[str]: + """Return request IDs that exist in the current Caido project.""" + if not request_ids: + return set() + + client = await _ctx_client(ctx) + if client is None: + raise RuntimeError("Caido client is not available") + + # Request IDs are not an HTTPQL field. Resolve each ID through the same + # project-bound lookup as view_request rather than constructing a filter. + existing: set[str] = set() + for request_id in request_ids: + result = await _call( + client, + functools.partial(caido_api.get_request_with_client, request_id=request_id), + ) + if result is not None: + existing.add(str(result.request.id)) + return existing + + def _to_tool_json(value: Any) -> Any: """Recursively convert SDK dataclasses/Pydantic objects to tool JSON values.""" if value is None or isinstance(value, str | int | float | bool): diff --git a/strix/tools/reporting/tool.py b/strix/tools/reporting/tool.py index 58c21a8e..2d245b69 100644 --- a/strix/tools/reporting/tool.py +++ b/strix/tools/reporting/tool.py @@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Any from agents import RunContextWrapper, function_tool from strix.tools.nullish import clean_optional +from strix.tools.proxy.tools import existing_request_ids if TYPE_CHECKING: @@ -168,6 +169,8 @@ _REQUIRED_FIELDS = { _VALID_FIX_EFFORT = frozenset({"trivial", "low", "medium", "high"}) _VALID_CONFIDENCE = frozenset({"high", "medium", "low"}) +_MAX_HTTP_EXCHANGE_IDS = 10 +_MAX_HTTP_EXCHANGE_ID_CHARS = 128 def _validate_required_text(fields: dict[str, str]) -> list[str]: @@ -177,6 +180,97 @@ def _validate_required_text(fields: dict[str, str]) -> list[str]: ] +def _normalize_http_exchange_ids(raw: Any) -> tuple[list[str] | None, list[str]]: + """Return distinct proxy exchange ids in their original order.""" + if raw is None: + return None, [] + if not isinstance(raw, list): + return None, ["http_exchange_ids must be a list of proxy request ids"] + + normalized: list[str] = [] + errors: list[str] = [] + seen: set[str] = set() + for index, value in enumerate(raw): + if not isinstance(value, str): + errors.append(f"http_exchange_ids[{index}] must be a string") + continue + request_id = value.strip() + if not request_id: + errors.append(f"http_exchange_ids[{index}] cannot be empty") + continue + if len(request_id) > _MAX_HTTP_EXCHANGE_ID_CHARS: + errors.append( + f"http_exchange_ids[{index}] must be {_MAX_HTTP_EXCHANGE_ID_CHARS} " + "characters or fewer" + ) + continue + if any(ord(char) < 0x21 or ord(char) > 0x7E for char in request_id): + errors.append(f"http_exchange_ids[{index}] must contain only visible ASCII characters") + continue + if not request_id.isdigit(): + errors.append(f"http_exchange_ids[{index}] must be a numeric proxy request id") + continue + if request_id not in seen: + seen.add(request_id) + normalized.append(request_id) + if len(normalized) > _MAX_HTTP_EXCHANGE_IDS: + errors.append( + f"http_exchange_ids can contain at most " + f"{_MAX_HTTP_EXCHANGE_IDS} distinct request ids" + ) + break + return normalized, errors + + +_HTTP_EXCHANGE_DROPPED_WARNING = ( + "http_exchange_ids were not stored: the proxy project could not be reached to verify " + "them. Attach them with update_vulnerability_report when the proxy responds again." +) + + +async def _verify_http_exchange_ids( + ctx: RunContextWrapper, + raw: Any, +) -> tuple[list[str] | None, list[str], str | None]: + """Verify proxy exchange IDs against the current Caido project. + + IDs the project does not know are rejected. When the proxy itself cannot be + queried the IDs are dropped and a warning is returned instead, so a proxy + outage never blocks a finding and unverified IDs are never recorded as + evidence. + """ + request_ids, errors = _normalize_http_exchange_ids(raw) + if request_ids is None or errors or not request_ids: + return request_ids, errors, None + + try: + existing_ids = await existing_request_ids(ctx, request_ids) + except Exception: # noqa: BLE001 + logger.warning( + "Could not verify HTTP exchange IDs against the current Caido project", + exc_info=True, + ) + return None, [], _HTTP_EXCHANGE_DROPPED_WARNING + + missing_ids = [request_id for request_id in request_ids if request_id not in existing_ids] + if missing_ids: + return ( + None, + [ + "http_exchange_ids do not exist in the current proxy project: " + + ", ".join(missing_ids) + ], + None, + ) + return request_ids, [], None + + +def _with_warning(result: dict[str, Any], warning: str | None) -> dict[str, Any]: + if warning and result.get("success"): + result["warning"] = warning + return result + + def _validate_cvss_breakdown(breakdown: Any) -> list[str]: """Check the 8 CVSS metrics are all present with legal values.""" if not isinstance(breakdown, dict) or not breakdown: @@ -299,7 +393,7 @@ _UPDATE_TEXT_FIELDS = ( ) -def _collect_update_changes( # noqa: PLR0912 +def _collect_update_changes( # noqa: PLR0912, PLR0915 fields: dict[str, Any], ) -> tuple[dict[str, Any], list[str]]: """Validate the fields a revision replaces and return them with any errors.""" @@ -368,6 +462,12 @@ def _collect_update_changes( # noqa: PLR0912 if cwe: changes["cwe"] = cwe + raw_http_exchange_ids = fields.get("http_exchange_ids") + http_exchange_ids, http_exchange_errors = _normalize_http_exchange_ids(raw_http_exchange_ids) + errors.extend(http_exchange_errors) + if raw_http_exchange_ids is not None and not http_exchange_errors: + changes["http_exchange_ids"] = http_exchange_ids or [] + return changes, errors @@ -378,6 +478,7 @@ _DYNAMIC_ONLY_UPDATE_FIELDS = ( "method", "poc_description", "poc_script_code", + "http_exchange_ids", ) # A dependency finding is rated in the context of the codebase that pins it, and @@ -551,13 +652,24 @@ def _do_update( if class_error is not None: return class_error - updated = report_state.update_vulnerability_report( - report_id, - changes, - update_reason=update_reason, - updated_by_agent_id=agent_id, - updated_by_agent_name=agent_name, - ) + try: + updated = report_state.update_vulnerability_report( + report_id, + changes, + update_reason=update_reason, + updated_by_agent_id=agent_id, + updated_by_agent_name=agent_name, + ) + except Exception as e: + logger.exception("update_vulnerability_report persistence failed") + return { + "success": False, + "error": ( + f"Failed to revise report '{report_id}': {e!s}. " + "The report still carries its previous content; retry the update." + ), + "report_id": report_id, + } if updated is None: known = [r.get("id") for r in report_state.get_existing_vulnerabilities()] if report_id not in known: @@ -606,6 +718,7 @@ async def _do_create( cve: str | None, cwe: str | None, code_locations: list[dict[str, Any]] | None, + http_exchange_ids: list[str] | None = None, confidence_rationale: str | None = None, fix_verification: str | None = None, fix_pr_body: str | None = None, @@ -651,6 +764,10 @@ async def _do_create( errors.extend(_validate_fix_verification(parsed_locations, fix_verification)) cve, cwe, identifier_errors = _validate_identifiers(cve, cwe) errors.extend(identifier_errors) + normalized_http_exchange_ids, http_exchange_errors = _normalize_http_exchange_ids( + http_exchange_ids + ) + errors.extend(http_exchange_errors) if errors: return {"success": False, "error": "Validation failed", "errors": errors} @@ -712,6 +829,7 @@ async def _do_create( "code_locations": parsed_locations, "fix_verification": fix_verification, "fix_pr_body": fix_pr_body, + "http_exchange_ids": normalized_http_exchange_ids, } dedupe = await check_duplicate(candidate, existing) @@ -738,9 +856,15 @@ async def _do_create( agent_id=agent_id if isinstance(agent_id, str) else None, agent_name=agent_name if isinstance(agent_name, str) else None, ) - except (ImportError, AttributeError) as e: + except Exception as e: logger.exception("create_vulnerability_report persistence failed") - return {"success": False, "error": f"Failed to create vulnerability report: {e!s}"} + return { + "success": False, + "error": ( + f"Failed to create vulnerability report: {e!s}. " + "The finding was not stored; file it again." + ), + } else: logger.info( "Vulnerability report created: id=%s severity=%s cvss=%.1f title=%s", @@ -796,6 +920,7 @@ async def create_vulnerability_report( cve: str | None = None, cwe: str | None = None, code_locations: list[dict[str, Any]] | None = None, + http_exchange_ids: list[str] | None = None, confidence_rationale: str | None = None, fix_verification: str | None = None, fix_pr_body: str | None = None, @@ -1040,6 +1165,18 @@ async def create_vulnerability_report( cve: ``CVE-YYYY-NNNNN`` if certain, else omit. cwe: ``CWE-NNN`` (most specific child) if certain, else omit. code_locations: White-box findings — list of location objects. + http_exchange_ids: Proxy request IDs that prove this finding. + Copy these IDs from ``list_requests`` or ``view_request``. + For a finding validated over HTTP, capture and inspect the + supporting exchanges and include their IDs here before filing. + Include relevant baseline/control requests as well as the exploit. + Omit only when the finding has no captured HTTP evidence (for + example a static-only code finding). Never invent IDs or drop + them to bypass a verification error; retry the capture instead. + If the result carries a ``warning`` that the IDs were not + stored, the finding is filed without them: attach them with + ``update_vulnerability_report`` once the proxy responds. + Keep IDs out of ``evidence`` and all other report text. **How ``fix_before`` / ``fix_after`` work**: they're used as literal GitHub/GitLab PR suggestion blocks. When a reviewer @@ -1187,6 +1324,22 @@ async def create_vulnerability_report( reduce impact and lower the severity. fix_effort: "low" """ + ( + http_exchange_ids, + http_exchange_errors, + http_exchange_warning, + ) = await _verify_http_exchange_ids(ctx, http_exchange_ids) + if http_exchange_errors: + return json.dumps( + { + "success": False, + "error": "Validation failed", + "errors": http_exchange_errors, + }, + ensure_ascii=False, + default=str, + ) + agent_id, agent_name = _caller_identity(ctx) result = await _do_create( @@ -1211,12 +1364,13 @@ async def create_vulnerability_report( cve=cve, cwe=cwe, code_locations=code_locations, + http_exchange_ids=http_exchange_ids, fix_verification=fix_verification, fix_pr_body=fix_pr_body, agent_id=agent_id, agent_name=agent_name, ) - return json.dumps(result, ensure_ascii=False, default=str) + return json.dumps(_with_warning(result, http_exchange_warning), ensure_ascii=False, default=str) @function_tool(timeout=60, strict_mode=False) @@ -1245,6 +1399,7 @@ async def update_vulnerability_report( cve: str | None = None, cwe: str | None = None, code_locations: list[dict[str, Any]] | None = None, + http_exchange_ids: list[str] | None = None, fix_verification: str | None = None, fix_pr_body: str | None = None, contextual_cvss_reasoning: str | None = None, @@ -1317,47 +1472,74 @@ async def update_vulnerability_report( cve: Replacement CVE id. cwe: Replacement CWE id. code_locations: Replacement code locations. + http_exchange_ids: Replacement proxy request ids. Pass an empty + list to remove all linked exchanges. fix_verification: Verification statement for an applyable fix. fix_pr_body: Replacement fix PR body. contextual_cvss_reasoning: Dependency findings only. What you observed in this codebase that justifies the contextual ``cvss_breakdown``. """ + ( + http_exchange_ids, + http_exchange_errors, + http_exchange_warning, + ) = await _verify_http_exchange_ids(ctx, http_exchange_ids) + if http_exchange_errors: + return json.dumps( + { + "success": False, + "error": "Validation failed", + "errors": http_exchange_errors, + }, + ensure_ascii=False, + default=str, + ) + + fields = { + "title": title, + "description": description, + "impact": impact, + "target": target, + "technical_analysis": technical_analysis, + "poc_description": poc_description, + "poc_script_code": poc_script_code, + "remediation_steps": remediation_steps, + "evidence": evidence, + "assumptions": assumptions, + "counterevidence": counterevidence, + "confidence": confidence, + "confidence_rationale": confidence_rationale, + "severity_change_conditions": severity_change_conditions, + "fix_effort": fix_effort, + "cvss_breakdown": cvss_breakdown, + "endpoint": endpoint, + "method": method, + "cve": cve, + "cwe": cwe, + "code_locations": code_locations, + "http_exchange_ids": http_exchange_ids, + "fix_verification": fix_verification, + "fix_pr_body": fix_pr_body, + "contextual_cvss_reasoning": contextual_cvss_reasoning, + } + if http_exchange_warning and all(value is None for value in fields.values()): + return json.dumps( + {"success": False, "error": http_exchange_warning, "report_id": report_id}, + ensure_ascii=False, + default=str, + ) + agent_id, agent_name = _caller_identity(ctx) result = await asyncio.to_thread( _do_update, report_id=report_id, update_reason=update_reason, - fields={ - "title": title, - "description": description, - "impact": impact, - "target": target, - "technical_analysis": technical_analysis, - "poc_description": poc_description, - "poc_script_code": poc_script_code, - "remediation_steps": remediation_steps, - "evidence": evidence, - "assumptions": assumptions, - "counterevidence": counterevidence, - "confidence": confidence, - "confidence_rationale": confidence_rationale, - "severity_change_conditions": severity_change_conditions, - "fix_effort": fix_effort, - "cvss_breakdown": cvss_breakdown, - "endpoint": endpoint, - "method": method, - "cve": cve, - "cwe": cwe, - "code_locations": code_locations, - "fix_verification": fix_verification, - "fix_pr_body": fix_pr_body, - "contextual_cvss_reasoning": contextual_cvss_reasoning, - }, + fields=fields, agent_id=agent_id, agent_name=agent_name, ) - return json.dumps(result, ensure_ascii=False, default=str) + return json.dumps(_with_warning(result, http_exchange_warning), ensure_ascii=False, default=str) _DEP_SEVERITY_FROM_CVSS = { @@ -1747,9 +1929,15 @@ async def _do_create_dependency( # noqa: PLR0912 agent_id=agent_id if isinstance(agent_id, str) else None, agent_name=agent_name if isinstance(agent_name, str) else None, ) - except (ImportError, AttributeError) as e: + except Exception as e: logger.exception("create_dependency_report persistence failed") - return {"success": False, "error": f"Failed to create dependency report: {e!s}"} + return { + "success": False, + "error": ( + f"Failed to create dependency report: {e!s}. " + "The finding was not stored; file it again." + ), + } else: logger.info( "Dependency report created: id=%s cve=%s package=%s severity=%s", diff --git a/tests/test_go_tui_runtime.py b/tests/test_go_tui_runtime.py index 05f81ece..afae99f1 100644 --- a/tests/test_go_tui_runtime.py +++ b/tests/test_go_tui_runtime.py @@ -19,6 +19,7 @@ from strix.config.settings import DEFAULT_MAX_TURNS from strix.interface.tui import runtime as go_tui from strix.interface.tui import sidecar from strix.interface.tui.runtime import GoTuiRuntime +from strix.report.state import ReportState def args() -> argparse.Namespace: @@ -1027,3 +1028,29 @@ async def test_prepare_and_start_runs_the_scan_after_preparation( assert order == ["preflight", "persist", "prepare", "telemetry", "state", "scan"] assert runtime.controller.scan_state == "running" + + +def test_sync_fingerprint_tracks_report_revisions(tmp_path: Path) -> None: + runtime = GoTuiRuntime(args()) + runtime.report_state = ReportState(run_name="test-run") + runtime.report_state.vulnerability_reports = [{"id": "vuln-0001", "title": "Old title"}] + runtime.report_state.get_run_dir = lambda: tmp_path # type: ignore[method-assign] + + report = runtime.report_state.vulnerability_reports[0] + timestamp = "2026-09-09 10:00:00 UTC" + + before = runtime._runtime_sync_fingerprint() + report.update( + { + "title": "New title", + "updated_at": timestamp, + "update_history": [{"timestamp": timestamp, "fields": ["title"]}], + } + ) + first_revision = runtime._runtime_sync_fingerprint() + assert first_revision != before + + report["title"] = "Newer title" + report["update_history"].append({"timestamp": timestamp, "fields": ["title"]}) + + assert runtime._runtime_sync_fingerprint() != first_revision diff --git a/tests/test_proxy_client.py b/tests/test_proxy_client.py index 1a3aa849..ec21925c 100644 --- a/tests/test_proxy_client.py +++ b/tests/test_proxy_client.py @@ -9,6 +9,7 @@ call at a time against the shared client. from __future__ import annotations import asyncio +from types import SimpleNamespace from typing import TYPE_CHECKING, Any, cast import pytest @@ -227,3 +228,27 @@ async def test_ctx_client_degrades_when_bootstrap_failed() -> None: handle = CaidoBootstrapHandle(asyncio.ensure_future(_bootstrap())) assert await tools._ctx_client(cast("Any", _Ctx({"caido_client": handle}))) is None + + +async def test_existing_request_ids_queries_current_project( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client = _FakeClient("host") + looked_up: list[str] = [] + + async def get_request_with_client(passed_client: Any, request_id: str) -> Any: + assert passed_client is client + looked_up.append(request_id) + if request_id == "1042": + return SimpleNamespace(request=SimpleNamespace(id="1042")) + return None + + monkeypatch.setattr(caido_api, "get_request_with_client", get_request_with_client) + + existing = await tools.existing_request_ids( + cast("Any", _Ctx({"caido_client": client})), + ["1042", "1088"], + ) + + assert existing == {"1042"} + assert looked_up == ["1042", "1088"] diff --git a/tests/test_reporting_fields.py b/tests/test_reporting_fields.py index edb393ea..aaac7582 100644 --- a/tests/test_reporting_fields.py +++ b/tests/test_reporting_fields.py @@ -2,9 +2,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +import json +from typing import TYPE_CHECKING, Any, cast import pytest +from agents.tool_context import ToolContext from strix.report.dedupe import ( _check_dependency_duplicate, @@ -13,10 +15,13 @@ from strix.report.dedupe import ( ) from strix.report.state import ReportState, set_global_report_state from strix.tools.finish.tool import finish_scan +from strix.tools.reporting import tool as reporting_tool from strix.tools.reporting.tool import ( _do_create, _do_create_dependency, _do_update, + _normalize_http_exchange_ids, + _verify_http_exchange_ids, create_dependency_report, create_vulnerability_report, update_vulnerability_report, @@ -115,6 +120,7 @@ async def test_create_report_persists_new_fields(report_state: ReportState) -> N cve=None, cwe="CWE-79", code_locations=None, + http_exchange_ids=["1042", "1042", "1088"], fix_pr_body="## Fix\nEncode output.", ) assert result["success"] is True @@ -127,6 +133,51 @@ async def test_create_report_persists_new_fields(report_state: ReportState) -> N assert report["counterevidence"] == "No output encoding or CSP observed on this response." assert report["confidence"] == "high" assert report["severity_change_conditions"] == "A strict CSP would lower the severity." + assert report["http_exchange_ids"] == ["1042", "1088"] + + +def test_create_report_does_not_commit_when_callback_fails( + report_state: ReportState, +) -> None: + def fail_persistence(_report: dict[str, Any]) -> None: + raise RuntimeError("persistence failed") + + report_state.vulnerability_found_callback = fail_persistence + + with pytest.raises(RuntimeError, match="persistence failed"): + report_state.add_vulnerability_report( + title="Unstored finding", + severity="high", + http_exchange_ids=["1042"], + ) + + assert report_state.vulnerability_reports == [] + + +def test_failed_revision_keeps_old_evidence_and_can_be_retried( + report_state: ReportState, +) -> None: + report_id = report_state.add_vulnerability_report( + title="Original finding", severity="high", http_exchange_ids=["1042"] + ) + original = dict(report_state.vulnerability_reports[0]) + + def fail_persistence(revised: dict[str, Any]) -> None: + assert revised["http_exchange_ids"] == ["1088"] + assert report_state.vulnerability_reports[0] == original + raise RuntimeError("persistence failed") + + report_state.vulnerability_updated_callback = fail_persistence + changes = {"title": "Revised finding", "http_exchange_ids": ["1088"]} + with pytest.raises(RuntimeError, match="persistence failed"): + report_state.update_vulnerability_report(report_id, changes) + assert report_state.vulnerability_reports[0] == original + + report_state.vulnerability_updated_callback = None + revised = report_state.update_vulnerability_report(report_id, changes) + assert revised is not None + assert revised["http_exchange_ids"] == ["1088"] + assert len(revised["update_history"]) == 1 async def test_create_report_requires_evidence_and_assumptions( @@ -1040,7 +1091,13 @@ def test_tool_descriptions_include_formatting_guidance() -> None: def test_vuln_tool_exposes_new_params() -> None: props = create_vulnerability_report.params_json_schema["properties"] - for field in ("evidence", "assumptions", "fix_effort", "fix_pr_body"): + for field in ( + "evidence", + "assumptions", + "fix_effort", + "fix_pr_body", + "http_exchange_ids", + ): assert field in props dep_props = create_dependency_report.params_json_schema["properties"] @@ -1355,6 +1412,158 @@ def test_update_vulnerability_report_records_chained_impact(report_state: Report assert report_state.update_vulnerability_report("vuln-0404", {"severity": "high"}) is None +def test_update_replaces_http_exchange_ids(report_state: ReportState) -> None: + _seed_weak_report(report_state) + + result = _do_update( + report_id="vuln-0009", + update_reason="A replay produced a clearer proving exchange.", + fields={"http_exchange_ids": ["204", "204", "205"]}, + ) + + assert result["success"] is True + assert report_state.vulnerability_reports[0]["http_exchange_ids"] == ["204", "205"] + + +async def test_create_rejects_invalid_http_exchange_ids(report_state: ReportState) -> None: + result = await _do_create( + **_CONFIRMED_KWARGS, + http_exchange_ids=["ok", "contains space"], + ) + + assert result["success"] is False + assert any("visible ASCII" in error for error in result["errors"]) + assert report_state.vulnerability_reports == [] + + +def test_http_exchange_id_limit_applies_after_deduplication() -> None: + request_ids, errors = _normalize_http_exchange_ids(["1042"] * 11) + + assert errors == [] + assert request_ids == ["1042"] + + +async def test_http_exchange_ids_must_exist_in_current_proxy_project( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def existing_request_ids( + _ctx: Any, + _request_ids: list[str], + ) -> set[str]: + return {"1042"} + + monkeypatch.setattr(reporting_tool, "existing_request_ids", existing_request_ids) + + request_ids, errors, warning = await _verify_http_exchange_ids( + cast("Any", object()), + ["1042", "1088"], + ) + + assert request_ids is None + assert errors == ["http_exchange_ids do not exist in the current proxy project: 1088"] + assert warning is None + + +async def test_http_exchange_ids_are_dropped_when_proxy_cannot_be_queried( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def existing_request_ids( + _ctx: Any, + _request_ids: list[str], + ) -> set[str]: + raise RuntimeError("Caido client is not available") + + monkeypatch.setattr(reporting_tool, "existing_request_ids", existing_request_ids) + + request_ids, errors, warning = await _verify_http_exchange_ids( + cast("Any", object()), + ["1042", "1042", "1088"], + ) + + assert request_ids is None + assert errors == [] + assert warning is not None + assert "not stored" in warning + assert "update_vulnerability_report" in warning + + +async def test_create_reports_persistence_failure_as_tool_error( + report_state: ReportState, +) -> None: + def fail_persistence(_report: dict[str, Any]) -> None: + raise RuntimeError("persistence failed") + + report_state.vulnerability_found_callback = fail_persistence + + result = await _do_create(**_CONFIRMED_KWARGS, http_exchange_ids=["1042"]) + + assert result["success"] is False + assert "persistence failed" in result["error"] + assert "file it again" in result["error"] + assert report_state.vulnerability_reports == [] + + +async def test_evidence_only_update_reports_proxy_outage_as_retryable( + report_state: ReportState, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _seed_weak_report(report_state) + original = dict(report_state.vulnerability_reports[0]) + + async def existing_request_ids( + _ctx: Any, + _request_ids: list[str], + ) -> set[str]: + raise RuntimeError("Caido client is not available") + + monkeypatch.setattr(reporting_tool, "existing_request_ids", existing_request_ids) + + ctx = ToolContext( + context={"agent_id": "root"}, + tool_name="update_vulnerability_report", + tool_call_id="call-1", + tool_arguments="{}", + ) + raw = await update_vulnerability_report.on_invoke_tool( + ctx, + json.dumps( + { + "report_id": "vuln-0009", + "update_reason": "A replay produced a clearer proving exchange.", + "http_exchange_ids": ["204"], + } + ), + ) + result = json.loads(raw) + + assert result["success"] is False + assert "No fields to update" not in result["error"] + assert "update_vulnerability_report" in result["error"] + assert result["report_id"] == "vuln-0009" + assert report_state.vulnerability_reports[0] == original + + +def test_update_reports_persistence_failure_as_tool_error(report_state: ReportState) -> None: + _seed_weak_report(report_state) + original = dict(report_state.vulnerability_reports[0]) + + def fail_persistence(_report: dict[str, Any]) -> None: + raise RuntimeError("persistence failed") + + report_state.vulnerability_updated_callback = fail_persistence + + result = _do_update( + report_id="vuln-0009", + update_reason="A replay produced a clearer proving exchange.", + fields={"http_exchange_ids": ["204"]}, + ) + + assert result["success"] is False + assert "persistence failed" in result["error"] + assert result["report_id"] == "vuln-0009" + assert report_state.vulnerability_reports[0] == original + + def test_update_vulnerability_report_ignores_identical_content(report_state: ReportState) -> None: _seed_weak_report(report_state) assert report_state.update_vulnerability_report("vuln-0009", {"severity": "medium"}) is None