diff --git a/backend/apps/events/adapters/heal_trigger.py b/backend/apps/events/adapters/heal_trigger.py index 6e3f45eb..5f937083 100644 --- a/backend/apps/events/adapters/heal_trigger.py +++ b/backend/apps/events/adapters/heal_trigger.py @@ -44,6 +44,16 @@ def parse_heal_reply(text: str) -> Tuple[Optional[str], str]: return fix, reason +async def probe_url(url: str) -> bool: + """A heal is only real if the fix demonstrably answers: SSRF-guarded GET, 2xx/3xx required. Model claims never applied unverified.""" + try: + from backend.apps.agents.tools.web import USER_AGENT, safe_fetch + resp = await safe_fetch(url, method="GET", headers={"User-Agent": USER_AGENT}, timeout=12.0) + return resp.status_code < 400 + except Exception: + return False + + async def attempt_heal(workflow_id: str, trigger: EventTriggerConfig) -> bool: """True when the trigger config was repaired (caller should re-poll now).""" from backend.apps.events import stores @@ -63,6 +73,12 @@ async def attempt_heal(workflow_id: str, trigger: EventTriggerConfig) -> bool: logger.warning("heal turn failed for trigger %s: %s", trigger.id, e) return False if fix and fix.startswith("http") and fix != url: + if not await probe_url(fix): + stores.append_log(workflow_id, EventLogEntry( + trigger_id=trigger.id, kind="error", + summary=f"Self-heal proposed {fix} but it didn't answer; not applied", + )) + return False wf = storage.get_workflow(workflow_id) if wf is None: return False diff --git a/backend/tests/test_event_stream_tier.py b/backend/tests/test_event_stream_tier.py index b708f5dd..c5c44228 100644 --- a/backend/tests/test_event_stream_tier.py +++ b/backend/tests/test_event_stream_tier.py @@ -176,6 +176,10 @@ def test_self_heal_fixes_url_or_escalates(make_wf, monkeypatch): assert "https://old.example/feed" in prompt and "410 Gone" in prompt return "Investigated.\nFIX_URL: https://new.example/feed" + async def p_probe_ok(url): + return url == "https://new.example/feed" + + monkeypatch.setattr(ht, "probe_url", p_probe_ok) monkeypatch.setattr(ac, "run_check_turn", p_fix_turn) assert p_run(ht.attempt_heal(wf.id, trig)) is True healed = storage.get_workflow(wf.id).event_triggers[0] @@ -190,3 +194,27 @@ def test_self_heal_fixes_url_or_escalates(make_wf, monkeypatch): assert p_run(ht.attempt_heal(wf.id, healed)) is False assert storage.get_workflow(wf.id).event_triggers[0].source.url == "https://new.example/feed" # untouched assert any("requires a sign-in" in e.summary for e in stores.read_log(wf.id)) + + +def test_heal_never_applies_an_unverified_fix(make_wf, monkeypatch): + """The model can claim any URL; only one that actually answers gets applied.""" + from backend.apps.events.adapters import agent_check as ac + from backend.apps.events.adapters import heal_trigger as ht + from backend.apps.events import stores + from backend.apps.workflows import storage + + trig = EventTriggerConfig(source=StreamSource(url="https://old.example/feed")) + wf = make_wf(event_triggers=[trig]) + storage.save_workflow(wf) + + async def p_fix_turn(model, prompt, **kwargs): + return "FIX_URL: https://hallucinated.example/feed" + + async def p_probe_dead(url): + return False + + monkeypatch.setattr(ac, "run_check_turn", p_fix_turn) + monkeypatch.setattr(ht, "probe_url", p_probe_dead) + assert p_run(ht.attempt_heal(wf.id, trig)) is False + assert storage.get_workflow(wf.id).event_triggers[0].source.url == "https://old.example/feed" + assert any("didn't answer; not applied" in e.summary for e in stores.read_log(wf.id))