Skip to content

Commit c71e5d6

Browse files
authored
Merge pull request #338 from spoo-me/fix/embed-future-links
fix(safety): say what a block does to future links, drop blocklist claim
2 parents 7667988 + 31d6e82 commit c71e5d6

7 files changed

Lines changed: 98 additions & 16 deletions

File tree

services/safety/analyzer.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,9 @@ async def _handle_toxic(
233233
legacy_count=result.legacy_count,
234234
sample_url=event.url,
235235
scope=scope_note,
236+
scope_kind={"host": "host", "path_pattern": "pattern", "links": "links"}[
237+
scope
238+
],
236239
follow_up=follow_up,
237240
)
238241

@@ -272,7 +275,7 @@ async def _reenforce(self, event: SafetyAnalyzeEvent, existing: VerdictDoc) -> b
272275
reason=reason,
273276
)
274277
await self._notify_reenforced(
275-
event, result, reason, scope=f"pattern: {pattern}"
278+
event, result, reason, scope=f"pattern: {pattern}", scope_kind="pattern"
276279
)
277280
return matching_blocked_pattern(event.url, (pattern,)) is not None
278281
# links scope: already blocked and the create gate refuses the exact URL.
@@ -326,10 +329,16 @@ async def _screen_redirect(self, event: SafetyAnalyzeEvent) -> None:
326329
legacy_count=result.legacy_count,
327330
sample_url=event.url,
328331
scope=f"the judged link only (reached through {event.host})",
332+
scope_kind="links",
329333
)
330334

331335
async def _notify_reenforced(
332-
self, event, result, reason: str, scope: str = "host-wide"
336+
self,
337+
event,
338+
result,
339+
reason: str,
340+
scope: str = "host-wide",
341+
scope_kind: str = "host",
333342
) -> None:
334343
"""Blocked something: the operator hears about it. Zero blocks stays quiet."""
335344
if result.blocked_count + result.legacy_count == 0:
@@ -342,6 +351,7 @@ async def _notify_reenforced(
342351
legacy_count=result.legacy_count,
343352
sample_url=event.url,
344353
scope=f"{scope} (re-enforced existing verdict)",
354+
scope_kind=scope_kind,
345355
)
346356

347357
async def _escalate(

services/safety/investigation.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,7 @@ async def _enact(
388388
legacy_count=result.legacy_count,
389389
sample_url=event.url,
390390
scope="host-wide",
391+
scope_kind="host",
391392
screenshot=shot,
392393
)
393394
elif decision.action == "block_aliases":
@@ -409,7 +410,7 @@ async def _enact(
409410
result.blocked_count,
410411
result.legacy_count,
411412
)
412-
scope_note = f"pattern: {pattern} (proposed for the blocklist)"
413+
scope_note, scope_kind = f"pattern: {pattern}", "pattern"
413414
else:
414415
pairs = await self._aliases_to_block(event)
415416
if pairs:
@@ -431,7 +432,10 @@ async def _enact(
431432
result.blocked_count,
432433
result.legacy_count,
433434
)
434-
scope_note = "specific links only, host left serving"
435+
scope_note, scope_kind = (
436+
"specific links only, host left serving",
437+
"links",
438+
)
435439
await self._notifier.safety_action(
436440
host=event.host,
437441
reason=verdict.reason,
@@ -440,6 +444,7 @@ async def _enact(
440444
legacy_count=legacy_count,
441445
sample_url=event.url,
442446
scope=scope_note,
447+
scope_kind=scope_kind,
443448
screenshot=shot,
444449
)
445450
elif decision.action == "apply_list":

services/safety/notify.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,23 @@
88

99
from __future__ import annotations
1010

11-
from typing import Any
11+
from typing import Any, Literal
1212

1313
from infrastructure.ops_notify import OpsNotifier
1414

1515
ACTION_COLOR = 15548997 # red: automatic enforcement happened
1616
REVIEW_COLOR = 16705372 # yellow: a human decision is needed
1717

18+
ScopeKind = Literal["host", "pattern", "links"]
19+
20+
# What the verdict store, which is the create-time gate, does from now on.
21+
# Nothing here touches the operator blocklist; saying so is the point.
22+
_FUTURE: dict[str, str] = {
23+
"host": "new links to this host are REFUSED at create (verdict store, not the blocklist)",
24+
"pattern": "new links matching the pattern are REFUSED at create; other paths on the host stay open",
25+
"links": "this URL (query variants included) is refused at create; the host stays open",
26+
}
27+
1828

1929
def _code(value: object) -> str:
2030
return f"```{value}```"
@@ -34,21 +44,25 @@ async def safety_action(
3444
legacy_count: int,
3545
sample_url: str | None,
3646
scope: str = "",
47+
scope_kind: ScopeKind | None = None,
3748
screenshot: bytes | None = None,
3849
follow_up: str = "",
3950
) -> bool:
4051
"""Enforcement already happened; this states the action taken.
41-
``scope`` is its own field because "host-wide" and "one link" are
42-
read very differently by the person deciding whether to intervene,
43-
and ``follow_up`` is separate so a link-scoped block never reads
44-
"host-wide decision..." beside a Scope that says otherwise."""
52+
``scope`` says how far the block reached on links that exist;
53+
``scope_kind`` adds what happens to the next link someone creates,
54+
because "host-wide" alone does not answer that. ``follow_up`` is
55+
separate so a link-scoped block never reads "host-wide decision..."
56+
beside a Scope that says otherwise."""
4557
fields: list[dict[str, Any]] = [
4658
{"name": "Destination Host", "value": _code(host)},
4759
{"name": "Scope", "value": _code(scope or "unspecified")},
4860
{"name": "Reason", "value": _code(reason)},
4961
{"name": "Trigger", "value": _code(trigger)},
5062
{"name": "Links Blocked (v2)", "value": _code(blocked_count)},
5163
]
64+
if scope_kind:
65+
fields.append({"name": "Future links", "value": _code(_FUTURE[scope_kind])})
5266
if legacy_count:
5367
fields.append(
5468
{"name": "Legacy v1/emoji Blocked", "value": _code(legacy_count)}

services/safety/providers.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
import time
1717
from dataclasses import dataclass
18-
from typing import Protocol
18+
from typing import Literal, Protocol
1919

2020
from infrastructure.logging import get_logger
2121
from infrastructure.web_risk import WebRiskClient
@@ -28,13 +28,15 @@
2828
log = get_logger(__name__)
2929

3030

31+
VerdictScope = Literal["host", "links", "path_pattern"]
32+
33+
3134
@dataclass(frozen=True)
3235
class ProviderVerdict:
3336
tier: VerdictTier
3437
reason: str
35-
# "host", "links" (one exact URL) or "path_pattern" (regex in
36-
# ``path_pattern``): how far the evidence reaches, not what it enforces.
37-
scope: str = "host"
38+
# How far the evidence reaches, not what it enforces.
39+
scope: VerdictScope = "host"
3840
path_pattern: str | None = None
3941

4042

tests/unit/services/safety/test_analyzer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,7 @@ async def test_reenforcement_that_blocks_new_links_notifies(self):
650650

651651
kw = notifier.safety_action.await_args.kwargs
652652
assert kw["scope"] == "host-wide (re-enforced existing verdict)"
653+
assert kw["scope_kind"] == "host"
653654
assert kw["reason"] == "old verdict"
654655

655656
@pytest.mark.asyncio

tests/unit/services/safety/test_investigation.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -399,11 +399,12 @@ async def test_pattern_reaches_the_operator_and_the_verdict(self):
399399
assert prov["scope"] == "path_pattern"
400400
assert "sites" in prov["path_pattern"]
401401
assert prov["scope_justification"] == "shared site builder, 210 creators"
402-
# The operator is told the pattern to add, since a pattern reaches
403-
# every FUTURE link and only a human may apply it.
402+
# The verdict store refuses future matches itself; nothing here writes
403+
# to the operator blocklist, so the embed must not say it does.
404404
kw = notifier.safety_action.await_args.kwargs
405405
assert kw["scope"].startswith("pattern: ")
406-
assert "proposed for the blocklist" in kw["scope"]
406+
assert kw["scope_kind"] == "pattern"
407+
assert "blocklist" not in kw["scope"]
407408
assert (
408409
"pattern" not in kw["reason"]
409410
) # the reason is the model's reason, nothing else
@@ -785,6 +786,7 @@ async def tool_call():
785786
enforcer.block_host.assert_awaited_once()
786787
kw = notifier.safety_action.await_args.kwargs
787788
assert kw["scope"] == "host-wide"
789+
assert kw["scope_kind"] == "host"
788790
# The judged URL's render, not the root fetched after it.
789791
assert kw["screenshot"] == b"the-page"
790792

tests/unit/services/safety/test_notify.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,51 @@ async def test_carries_context_and_image(self):
111111
f = _fields(ops)
112112
assert "classification: uncertain" in f["Context"]
113113
assert f["Sample URL"] == "```https://h/x```"
114+
115+
116+
class TestFutureLinks:
117+
"""Scope says how far the block reached on links that already exist.
118+
It says nothing about the next link someone creates, and it must never
119+
imply the operator blocklist changed: nothing in the pipeline writes to
120+
it, the verdict store is the create-time gate."""
121+
122+
@pytest.mark.asyncio
123+
async def test_host_kind_says_new_links_are_refused(self):
124+
n, ops = _notifier()
125+
await _action(n, scope="host-wide", scope_kind="host")
126+
f = _fields(ops)["Future links"]
127+
assert "new links to this host are REFUSED at create" in f
128+
assert "not the blocklist" in f
129+
130+
@pytest.mark.asyncio
131+
async def test_pattern_kind_says_matches_refused_host_stays_open(self):
132+
n, ops = _notifier()
133+
await _action(
134+
n, scope="pattern: ^https://evil.example/l.*", scope_kind="pattern"
135+
)
136+
f = _fields(ops)["Future links"]
137+
assert "matching the pattern are REFUSED" in f
138+
assert "other paths on the host stay open" in f
139+
140+
@pytest.mark.asyncio
141+
async def test_links_kind_says_host_stays_open(self):
142+
n, ops = _notifier()
143+
await _action(n, scope="the judged link only", scope_kind="links")
144+
f = _fields(ops)["Future links"]
145+
assert "query variants included" in f
146+
assert "the host stays open" in f
147+
148+
@pytest.mark.asyncio
149+
async def test_no_kind_no_field(self):
150+
n, ops = _notifier()
151+
await _action(n, scope="host-wide")
152+
assert "Future links" not in _fields(ops)
153+
154+
def test_future_text_never_promises_the_blocklist(self):
155+
"""A "proposed for the blocklist" claim shipped and survived two
156+
review rounds. Pin it out."""
157+
from services.safety.notify import _FUTURE
158+
159+
for text in _FUTURE.values():
160+
assert "proposed" not in text
161+
assert "blocklist" not in text.replace("not the blocklist", "")

0 commit comments

Comments
 (0)