Skip to content

Commit 6614056

Browse files
committed
feat(safety): say how far a block reached, and show what the model saw
The auto-block embed said a host was blocked and how many links, but not whether that meant the whole domain, one pattern, or a single link. The scope was tucked into the reason as a parenthetical on some paths and absent on others. It is now its own field on every block embed, and the reason carries only the model's reason. The screenshot the model judged never left the agent run. fetch_page now leaves it in a mutable contextvar holder, the same shape as the hard-hit flag and for the same create_task reason, and the investigator attaches it to both the block and the review embed. Discord takes it as a multipart file the embed references by attachment name. Screening-tier blocks have no render and attach nothing.
1 parent f772a5d commit 6614056

8 files changed

Lines changed: 255 additions & 18 deletions

File tree

infrastructure/ops_notify.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from __future__ import annotations
1616

17+
import json
1718
from datetime import datetime, timezone
1819
from typing import Any, Protocol
1920

@@ -22,6 +23,7 @@
2223

2324
log = get_logger(__name__)
2425

26+
_SHOT_NAME = "evidence.webp"
2527
_FOOTER = {
2628
"text": "spoo-me",
2729
"icon_url": "https://spoo.me/static/images/favicon.png",
@@ -66,6 +68,8 @@ async def safety_action(
6668
blocked_count: int,
6769
legacy_count: int,
6870
sample_url: str | None,
71+
scope: str = "",
72+
screenshot: bytes | None = None,
6973
) -> bool: ...
7074

7175
async def safety_review(
@@ -75,6 +79,7 @@ async def safety_review(
7579
trigger: str,
7680
sample_url: str | None,
7781
context: dict | None,
82+
screenshot: bytes | None = None,
7883
) -> bool: ...
7984

8085

@@ -215,12 +220,17 @@ async def safety_action(
215220
blocked_count: int,
216221
legacy_count: int,
217222
sample_url: str | None,
223+
scope: str = "",
224+
screenshot: bytes | None = None,
218225
) -> bool:
219226
"""Enforcement already happened — this states the ACTION TAKEN, it
220227
is not a request for one. ``legacy_count`` is v1/emoji links
221-
blocked via their safety flag (same 451 as v2, reversible)."""
228+
blocked via their safety flag (same 451 as v2, reversible).
229+
``scope`` is its own field: "host-wide" and "one link" are read
230+
very differently by the person deciding whether to intervene."""
222231
fields: list[dict[str, Any]] = [
223232
{"name": "Destination Host", "value": f"```{host}```"},
233+
{"name": "Scope", "value": f"```{scope or 'unspecified'}```"},
224234
{"name": "Reason", "value": f"```{reason}```"},
225235
{"name": "Trigger", "value": f"```{trigger}```"},
226236
{"name": "Links Blocked (v2)", "value": f"```{blocked_count}```"},
@@ -245,7 +255,9 @@ async def safety_action(
245255
}
246256
]
247257
}
248-
return await self._deliver(self._report_url, payload, kind="safety_action")
258+
return await self._deliver(
259+
self._report_url, payload, kind="safety_action", screenshot=screenshot
260+
)
249261

250262
async def safety_review(
251263
self,
@@ -254,6 +266,7 @@ async def safety_review(
254266
trigger: str,
255267
sample_url: str | None,
256268
context: dict | None,
269+
screenshot: bytes | None = None,
257270
) -> bool:
258271
"""No local source could judge this destination — a human decision
259272
is needed. Carries the trigger context so the decision takes
@@ -280,16 +293,35 @@ async def safety_review(
280293
}
281294
]
282295
}
283-
return await self._deliver(self._report_url, payload, kind="safety_review")
296+
return await self._deliver(
297+
self._report_url, payload, kind="safety_review", screenshot=screenshot
298+
)
284299

285300
# ── Delivery ──────────────────────────────────────────────────────────────
286301

287-
async def _deliver(self, url: str, payload: dict[str, Any], *, kind: str) -> bool:
302+
async def _deliver(
303+
self,
304+
url: str,
305+
payload: dict[str, Any],
306+
*,
307+
kind: str,
308+
screenshot: bytes | None = None,
309+
) -> bool:
288310
if not url:
289311
log.warning("ops_notify_not_configured", kind=kind)
290312
return False
291313
try:
292-
response = await self._http.post(url, json=payload)
314+
if screenshot:
315+
# Discord webhooks take the image as a multipart file; the
316+
# embed refers to it by attachment name.
317+
payload["embeds"][0]["image"] = {"url": f"attachment://{_SHOT_NAME}"}
318+
response = await self._http.post(
319+
url,
320+
data={"payload_json": json.dumps(payload)},
321+
files={"files[0]": (_SHOT_NAME, screenshot, "image/webp")},
322+
)
323+
else:
324+
response = await self._http.post(url, json=payload)
293325
if response.status_code in (200, 204):
294326
return True
295327
log.warning(

services/safety/analyzer.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -197,14 +197,14 @@ async def _handle_toxic(
197197
matcher=lambda u: matching_blocked_pattern(u, (pattern,)) is not None,
198198
reason=verdict.reason,
199199
)
200-
scope_note = f"scoped to pattern {pattern}"
200+
scope_note = f"pattern: {pattern}"
201201
else:
202202
result = await self._enforcer.block_matching(
203203
event.host,
204204
matcher=lambda u, _u=event.url: u == _u,
205205
reason=verdict.reason,
206206
)
207-
scope_note = "scoped to the judged URL"
207+
scope_note = "the judged link only"
208208

209209
await self._verdict_repo.upsert_verdict(
210210
event.host,
@@ -227,11 +227,12 @@ async def _handle_toxic(
227227
follow_up = "host-wide decision needs review"
228228
await self._notifier.safety_action(
229229
host=event.host,
230-
reason=f"{verdict.reason} ({scope_note}; {follow_up})",
230+
reason=f"{verdict.reason} ({follow_up})",
231231
trigger=event.trigger,
232232
blocked_count=result.blocked_count,
233233
legacy_count=result.legacy_count,
234234
sample_url=event.url,
235+
scope=scope_note,
235236
)
236237

237238
async def _warn_if_shared_carrier(
@@ -269,7 +270,9 @@ async def _reenforce(self, event: SafetyAnalyzeEvent, existing: VerdictDoc) -> b
269270
matcher=lambda u: matching_blocked_pattern(u, (pattern,)) is not None,
270271
reason=reason,
271272
)
272-
await self._notify_reenforced(event, result, reason)
273+
await self._notify_reenforced(
274+
event, result, reason, scope=f"pattern: {pattern}"
275+
)
273276
return matching_blocked_pattern(event.url, (pattern,)) is not None
274277
# links scope: already blocked and the create gate refuses the exact URL.
275278
return event.url == existing.sample_url
@@ -316,24 +319,28 @@ async def _screen_redirect(self, event: SafetyAnalyzeEvent) -> None:
316319
)
317320
await self._notifier.safety_action(
318321
host=parts["host"],
319-
reason=f"{reason} (reached through {event.host})",
322+
reason=reason,
320323
trigger=event.trigger,
321324
blocked_count=result.blocked_count,
322325
legacy_count=result.legacy_count,
323326
sample_url=event.url,
327+
scope=f"the judged link only (reached through {event.host})",
324328
)
325329

326-
async def _notify_reenforced(self, event, result, reason: str) -> None:
330+
async def _notify_reenforced(
331+
self, event, result, reason: str, scope: str = "host-wide"
332+
) -> None:
327333
"""Blocked something: the operator hears about it. Zero blocks stays quiet."""
328334
if result.blocked_count + result.legacy_count == 0:
329335
return
330336
await self._notifier.safety_action(
331337
host=event.host,
332-
reason=f"{reason} (re-enforced existing verdict)",
338+
reason=reason,
333339
trigger=event.trigger,
334340
blocked_count=result.blocked_count,
335341
legacy_count=result.legacy_count,
336342
sample_url=event.url,
343+
scope=f"{scope} (re-enforced existing verdict)",
337344
)
338345

339346
async def _escalate(

services/safety/investigation.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,12 @@
3636
from services.safety.events import SILENT_TRIGGERS, SafetyAnalyzeEvent
3737
from services.safety.feeds import REDIRECTOR_FEED
3838
from services.safety.providers import without_query
39-
from services.safety.tools import reset_hard_hit, saw_hard_hit
39+
from services.safety.tools import (
40+
last_render_screenshot,
41+
reset_hard_hit,
42+
reset_last_render,
43+
saw_hard_hit,
44+
)
4045
from shared.validators import is_valid_pattern, matching_blocked_pattern
4146

4247
log = get_logger(__name__)
@@ -282,6 +287,7 @@ def __init__(
282287
async def investigate(self, event: SafetyAnalyzeEvent) -> None:
283288
bundle = await build_evidence_bundle(event, self._url_repo)
284289
reset_hard_hit()
290+
reset_last_render()
285291
try:
286292
verdict: InvestigationVerdict = await self._runner.run(self._task, bundle)
287293
except LlmTaskFailed as exc:
@@ -371,6 +377,7 @@ async def _enact(
371377
verdict: InvestigationVerdict,
372378
decision: AuthorityDecision,
373379
) -> None:
380+
shot = last_render_screenshot() or None
374381
if decision.action == "block_host":
375382
result = await self._enforcer.block_host(event.host, reason=verdict.reason)
376383
await self._notifier.safety_action(
@@ -380,6 +387,8 @@ async def _enact(
380387
blocked_count=result.blocked_count,
381388
legacy_count=result.legacy_count,
382389
sample_url=event.url,
390+
scope="host-wide",
391+
screenshot=shot,
383392
)
384393
elif decision.action == "block_aliases":
385394
if (
@@ -400,7 +409,7 @@ async def _enact(
400409
result.blocked_count,
401410
result.legacy_count,
402411
)
403-
scope_note = f"pattern proposed for the blocklist: {pattern}"
412+
scope_note = f"pattern: {pattern} (proposed for the blocklist)"
404413
else:
405414
pairs = await self._aliases_to_block(event)
406415
if pairs:
@@ -425,11 +434,13 @@ async def _enact(
425434
scope_note = "specific links only, host left serving"
426435
await self._notifier.safety_action(
427436
host=event.host,
428-
reason=f"{verdict.reason} ({scope_note})",
437+
reason=verdict.reason,
429438
trigger=event.trigger,
430439
blocked_count=blocked_count,
431440
legacy_count=legacy_count,
432441
sample_url=event.url,
442+
scope=scope_note,
443+
screenshot=shot,
433444
)
434445
elif decision.action == "apply_list":
435446
if self._feed_repo is None:
@@ -469,6 +480,7 @@ async def _enact(
469480
host=event.host,
470481
trigger=event.trigger,
471482
sample_url=event.url,
483+
screenshot=shot,
472484
context={
473485
**(event.context or {}),
474486
"classification": verdict.classification.value,

services/safety/tools.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,31 @@ def reset_hard_hit() -> None:
7777
_hard_hit.set(_HardHitFlag())
7878

7979

80+
class _LastRender:
81+
def __init__(self) -> None:
82+
self.screenshot: bytes = b""
83+
self.url: str = ""
84+
85+
86+
# Same mutable-holder shape as the hard-hit flag, for the same create_task
87+
# reason: fetch_page runs in a child task and must mutate, never rebind.
88+
_last_render: contextvars.ContextVar[_LastRender | None] = contextvars.ContextVar(
89+
"safety_last_render", default=None
90+
)
91+
92+
93+
def reset_last_render() -> None:
94+
_last_render.set(_LastRender())
95+
96+
97+
def last_render_screenshot() -> bytes:
98+
"""The most recent non-empty screenshot this investigation rendered, or
99+
b"". It is the evidence the operator embed should show alongside the
100+
verdict, since the model's reason describes what was in it."""
101+
held = _last_render.get()
102+
return held.screenshot if held is not None else b""
103+
104+
80105
def saw_hard_hit() -> bool:
81106
flag = _hard_hit.get()
82107
return flag is not None and flag.hit
@@ -523,6 +548,9 @@ async def fetch_page(url: str) -> str | ToolReturn:
523548
text = f"rendered via {result.egress}\nurl: {url}\n{trimmed}"
524549
if not result.screenshot:
525550
return text
551+
held = _last_render.get()
552+
if held is not None:
553+
held.screenshot, held.url = result.screenshot, url
526554
return ToolReturn(
527555
return_value=f"{text}\nscreenshot: attached",
528556
content=[BinaryContent(data=result.screenshot, media_type="image/webp")],

tests/unit/infrastructure/test_ops_notify.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,88 @@ async def test_embed_url_points_to_stats_page(self):
100100
await notifier.url_report("abc123", "spam", "1.2.3.4", "https://spoo.me/")
101101
embed = http.post.call_args.kwargs["json"]["embeds"][0]
102102
assert embed["url"] == "https://spoo.me/stats/abc123"
103+
104+
105+
def _action(notifier, **over):
106+
base = dict(
107+
host="evil.example",
108+
reason="fake bank login",
109+
trigger="sweep",
110+
blocked_count=3,
111+
legacy_count=0,
112+
sample_url="https://evil.example/x",
113+
)
114+
base.update(over)
115+
return notifier.safety_action(**base)
116+
117+
118+
class TestScopeField:
119+
""" "host-wide" and "one link" are read very differently by the person
120+
deciding whether to intervene, so scope is its own field, not a clause
121+
tucked into the reason."""
122+
123+
async def test_scope_is_its_own_field(self):
124+
notifier, http = _make()
125+
await _action(
126+
notifier, scope="pattern: ^https://sites\\.google\\.com/view/evil/.*"
127+
)
128+
fields = {
129+
f["name"]: f["value"]
130+
for f in http.post.call_args.kwargs["json"]["embeds"][0]["fields"]
131+
}
132+
assert (
133+
fields["Scope"]
134+
== "```pattern: ^https://sites\\.google\\.com/view/evil/.*```"
135+
)
136+
assert fields["Reason"] == "```fake bank login```"
137+
138+
async def test_missing_scope_is_named_not_omitted(self):
139+
notifier, http = _make()
140+
await _action(notifier)
141+
fields = {
142+
f["name"]: f["value"]
143+
for f in http.post.call_args.kwargs["json"]["embeds"][0]["fields"]
144+
}
145+
assert fields["Scope"] == "```unspecified```"
146+
147+
148+
class TestScreenshotAttachment:
149+
"""The evidence the model judged should sit next to the verdict. Discord
150+
takes it as a multipart file the embed points at by attachment name."""
151+
152+
async def test_block_embed_carries_the_screenshot_as_multipart(self):
153+
import json
154+
155+
notifier, http = _make()
156+
await _action(notifier, scope="host-wide", screenshot=b"webp!")
157+
kw = http.post.call_args.kwargs
158+
assert "json" not in kw
159+
assert kw["files"]["files[0]"] == ("evidence.webp", b"webp!", "image/webp")
160+
payload = json.loads(kw["data"]["payload_json"])
161+
assert payload["embeds"][0]["image"] == {"url": "attachment://evidence.webp"}
162+
assert payload["embeds"][0]["title"] == "Safety: destination auto-blocked"
163+
164+
async def test_review_embed_carries_the_screenshot_too(self):
165+
import json
166+
167+
notifier, http = _make()
168+
await notifier.safety_review(
169+
host="h",
170+
trigger="report",
171+
sample_url="https://h/x",
172+
context={"k": "v"},
173+
screenshot=b"shot",
174+
)
175+
kw = http.post.call_args.kwargs
176+
assert kw["files"]["files[0]"][1] == b"shot"
177+
assert (
178+
json.loads(kw["data"]["payload_json"])["embeds"][0]["image"]["url"]
179+
== "attachment://evidence.webp"
180+
)
181+
182+
async def test_no_screenshot_stays_plain_json(self):
183+
notifier, http = _make()
184+
await _action(notifier, scope="host-wide")
185+
kw = http.post.call_args.kwargs
186+
assert "json" in kw and "files" not in kw and "data" not in kw
187+
assert "image" not in kw["json"]["embeds"][0]

tests/unit/services/safety/test_analyzer.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -641,8 +641,9 @@ async def test_reenforcement_that_blocks_new_links_notifies(self):
641641

642642
await analyzer.analyze(_event())
643643

644-
reason = notifier.safety_action.await_args.kwargs["reason"]
645-
assert "re-enforced" in reason
644+
kw = notifier.safety_action.await_args.kwargs
645+
assert kw["scope"] == "host-wide (re-enforced existing verdict)"
646+
assert kw["reason"] == "old verdict"
646647

647648
@pytest.mark.asyncio
648649
async def test_idempotent_reenforcement_stays_quiet(self):

0 commit comments

Comments
 (0)