Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion s16code/events/governor.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,15 @@ def admit_event(self, event: EventEnvelope, *, now: datetime | None = None) -> V
return Verdict(False, f"event was caused by this agent ({actor}); refusing to answer itself",
"self_trigger", {"actor": actor})

if self.source_events_per_minute > 0:
if self.source_events_per_minute is not None and self.source_events_per_minute >= 0:
if self.source_events_per_minute == 0:
return Verdict(
False,
f"source {event.source!r} blocked: source_events_per_minute is 0",
"source_rate_limit",
{"source": event.source, "observed": 0,
"limit": 0},
)
recent = self.store.count_recent_events(event.source, since=moment - timedelta(minutes=1))
if recent >= self.source_events_per_minute:
return Verdict(
Expand Down
65 changes: 65 additions & 0 deletions tests/test_zero_source_rate_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Test: source_events_per_minute=0 must block all events, not disable the limit.

Bug: In AutonomyGovernor.admit_event(), the check:
if self.source_events_per_minute > 0:
skips the rate limit entirely when the value is 0. An operator setting
source_events_per_minute=0 intends "block all events from this source",
but actually gets "allow unlimited events" — the control does not hold.

Fix: Change the guard to >= 0 and handle the zero case explicitly.
"""

from datetime import UTC, datetime

from s16code.events import AutonomyGovernor, EventEnvelope, EventStore


def _event(**changes) -> EventEnvelope:
body = {"id": "e1", "source": "webhooks", "type": "webhook.received",
"occurred_at": datetime.now(UTC), "data": {}}
body.update(changes)
return EventEnvelope(**body)


def test_zero_source_rate_limit_blocks_all_events(tmp_path) -> None:
"""source_events_per_minute=0 must refuse every event, not skip the check."""
store = EventStore(tmp_path / "state")
governor = AutonomyGovernor(store, source_events_per_minute=0)

event = _event()
verdict = governor.admit_event(event)

# Before the fix: verdict.admitted is True (rate limit skipped)
# After the fix: verdict.admitted is False (all events blocked)
assert verdict.admitted is False, (
f"Expected event to be refused when source_events_per_minute=0, "
f"but it was admitted. The rate limit was bypassed!"
)
assert verdict.control == "source_rate_limit"
assert "0" in verdict.reason


def test_positive_source_rate_limit_still_works(tmp_path) -> None:
"""A positive limit must still admit events below the threshold."""
store = EventStore(tmp_path / "state")
governor = AutonomyGovernor(store, source_events_per_minute=5)

event = _event()
verdict = governor.admit_event(event)

assert verdict.admitted is True


def test_source_rate_limit_refuses_after_threshold(tmp_path) -> None:
"""Events beyond the limit must be refused."""
store = EventStore(tmp_path / "state")
governor = AutonomyGovernor(store, source_events_per_minute=2)

# Ingest 2 events to fill the window
store.ingest(_event(id="e1"))
store.ingest(_event(id="e2"))

# Third event should be refused
verdict = governor.admit_event(_event(id="e3"))
assert verdict.admitted is False
assert verdict.control == "source_rate_limit"