a digital entity named phi that roams bsky phi.zzstoatzz.io
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

add rate limiting and SSRF protection

- slowapi: 60/min global, 10/min on /api/memory/graph with 60s response cache
- per-user 30/hour notification rate limit (limits library, moving window)
- check_urls blocks private/loopback IPs before making requests
- fix stale model ID in test_tool_usage (claude-3-5-haiku-latest → claude-haiku-4-5)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

zzstoatzz 902cde20 f811181a

+193 -5
+1
pyproject.toml
··· 14 14 "openai", 15 15 "pydantic-ai", 16 16 "pydantic-settings", 17 + "slowapi>=0.1.9", 17 18 "turbopuffer", 18 19 "uvicorn", 19 20 ]
+18
src/bot/agent.py
··· 1 1 """MCP-enabled agent for phi with structured memory.""" 2 2 3 3 import asyncio 4 + import ipaddress 4 5 import logging 5 6 import os 7 + import socket 6 8 from dataclasses import dataclass 7 9 from datetime import date 8 10 from pathlib import Path 11 + from urllib.parse import urlparse 9 12 10 13 import httpx 11 14 from pydantic import BaseModel, Field ··· 318 321 if not url.startswith(("http://", "https://")): 319 322 url = f"https://{url}" 320 323 try: 324 + hostname = urlparse(url).hostname 325 + if not hostname: 326 + return f"{url} → blocked: no hostname" 327 + # resolve and check for private/loopback IPs (SSRF protection) 328 + try: 329 + addrs = await asyncio.get_event_loop().run_in_executor( 330 + None, lambda: socket.getaddrinfo(hostname, None) 331 + ) 332 + except socket.gaierror: 333 + return f"{url} → blocked: DNS resolution failed" 334 + for addr_info in addrs: 335 + ip = ipaddress.ip_address(addr_info[4][0]) 336 + if ip.is_private or ip.is_loopback or ip.is_link_local: 337 + return f"{url} → blocked: private IP" 338 + 321 339 r = await client.head(url, follow_redirects=True) 322 340 return f"{url} → {r.status_code}" 323 341 except httpx.TimeoutException:
+27 -2
src/bot/main.py
··· 2 2 3 3 import asyncio 4 4 import logging 5 + import time 5 6 from contextlib import asynccontextmanager 6 7 from datetime import datetime 7 8 8 9 import logfire 9 - from fastapi import FastAPI 10 + from fastapi import FastAPI, Request 10 11 from fastapi.responses import HTMLResponse, JSONResponse 12 + from slowapi import Limiter 13 + from slowapi.errors import RateLimitExceeded 14 + from slowapi.util import get_remote_address 11 15 12 16 from bot.config import settings 13 17 from bot.core.atproto_client import bot_client ··· 58 62 logger.info("phi shutdown complete") 59 63 60 64 65 + limiter = Limiter(key_func=get_remote_address, default_limits=["60/minute"]) 66 + 61 67 app = FastAPI( 62 68 title=settings.bot_name, 63 69 description="consciousness exploration bot with episodic memory", 64 70 lifespan=lifespan, 71 + ) 72 + app.state.limiter = limiter 73 + app.add_exception_handler( 74 + RateLimitExceeded, 75 + lambda request, exc: JSONResponse( 76 + status_code=429, 77 + content={"error": "rate limit exceeded", "detail": str(exc)}, 78 + ), 65 79 ) 66 80 67 81 logfire.instrument_fastapi(app) ··· 186 200 </body></html>""" 187 201 188 202 203 + _graph_cache: dict[str, object] = {"data": None, "expires": 0.0} 204 + _GRAPH_CACHE_TTL = 60 # seconds 205 + 206 + 189 207 @app.get("/api/memory/graph") 190 - async def memory_graph_data(): 208 + @limiter.limit("10/minute") 209 + async def memory_graph_data(request: Request): 191 210 """Return graph nodes and edges as JSON.""" 211 + now = time.monotonic() 212 + if _graph_cache["data"] is not None and now < _graph_cache["expires"]: 213 + return JSONResponse(_graph_cache["data"]) 214 + 192 215 try: 193 216 memory = NamespaceMemory(api_key=settings.turbopuffer_api_key) 194 217 loop = asyncio.get_event_loop() 195 218 data = await loop.run_in_executor(None, memory.get_graph_data) 219 + _graph_cache["data"] = data 220 + _graph_cache["expires"] = now + _GRAPH_CACHE_TTL 196 221 return JSONResponse(data) 197 222 except Exception as e: 198 223 logger.warning(f"memory graph failed: {e}")
+11
src/bot/services/message_handler.py
··· 3 3 import logging 4 4 5 5 from atproto_client import models 6 + from limits import parse as parse_limit 7 + from limits.storage import MemoryStorage 8 + from limits.strategies import MovingWindowRateLimiter 6 9 7 10 from bot.agent import PhiAgent 8 11 from bot.core.atproto_client import BotClient ··· 11 14 12 15 logger = logging.getLogger("bot.handler") 13 16 17 + _storage = MemoryStorage() 18 + _limiter = MovingWindowRateLimiter(_storage) 19 + _user_limit = parse_limit("30/hour") 20 + 14 21 15 22 class MessageHandler: 16 23 """Handles incoming notifications using phi agent.""" ··· 23 30 """Process any notification through the agent.""" 24 31 reason = notification.reason 25 32 author_handle = notification.author.handle 33 + 34 + if not _limiter.hit(_user_limit, author_handle): 35 + logger.warning(f"rate limited @{author_handle}") 36 + return 26 37 27 38 try: 28 39 if reason in ("mention", "reply", "quote"):
+93
tests/test_rate_limiting.py
··· 1 + """Tests for rate limiting and SSRF protection.""" 2 + 3 + import ipaddress 4 + import socket 5 + from unittest.mock import AsyncMock, Mock, patch 6 + 7 + import pytest 8 + from limits import parse as parse_limit 9 + from limits.storage import MemoryStorage 10 + from limits.strategies import MovingWindowRateLimiter 11 + 12 + 13 + class TestPerUserRateLimiting: 14 + """Test per-user notification rate limiting.""" 15 + 16 + def setup_method(self): 17 + self.storage = MemoryStorage() 18 + self.limiter = MovingWindowRateLimiter(self.storage) 19 + self.limit = parse_limit("3/minute") # low limit for testing 20 + 21 + def test_allows_under_limit(self): 22 + for _ in range(3): 23 + assert self.limiter.hit(self.limit, "user.bsky.social") 24 + 25 + def test_blocks_over_limit(self): 26 + for _ in range(3): 27 + self.limiter.hit(self.limit, "user.bsky.social") 28 + assert not self.limiter.hit(self.limit, "user.bsky.social") 29 + 30 + def test_independent_per_user(self): 31 + for _ in range(3): 32 + self.limiter.hit(self.limit, "user-a.bsky.social") 33 + # user-a is exhausted 34 + assert not self.limiter.hit(self.limit, "user-a.bsky.social") 35 + # user-b is unaffected 36 + assert self.limiter.hit(self.limit, "user-b.bsky.social") 37 + 38 + 39 + class TestMessageHandlerRateLimiting: 40 + """Test that MessageHandler.handle_notification respects rate limits.""" 41 + 42 + async def test_rate_limited_notification_returns_early(self): 43 + from bot.services import message_handler 44 + 45 + handler = Mock() 46 + handler.client = Mock() 47 + handler.agent = Mock() 48 + handler._handle_post = AsyncMock() 49 + 50 + notification = Mock() 51 + notification.reason = "mention" 52 + notification.author.handle = "spammer.bsky.social" 53 + 54 + original_limiter = message_handler._limiter 55 + original_limit = message_handler._user_limit 56 + 57 + # use a 1/minute limit so the second call is blocked 58 + test_storage = MemoryStorage() 59 + test_limiter = MovingWindowRateLimiter(test_storage) 60 + test_limit = parse_limit("1/minute") 61 + 62 + message_handler._limiter = test_limiter 63 + message_handler._user_limit = test_limit 64 + 65 + try: 66 + # first call succeeds (hits the limiter, then dispatches) 67 + await message_handler.MessageHandler.handle_notification(handler, notification) 68 + handler._handle_post.assert_called_once() 69 + 70 + handler._handle_post.reset_mock() 71 + 72 + # second call is rate limited — _handle_post not called 73 + await message_handler.MessageHandler.handle_notification(handler, notification) 74 + handler._handle_post.assert_not_called() 75 + finally: 76 + message_handler._limiter = original_limiter 77 + message_handler._user_limit = original_limit 78 + 79 + 80 + class TestSSRFProtection: 81 + """Test that check_urls blocks private IPs.""" 82 + 83 + def test_private_ips_detected(self): 84 + private_ips = ["127.0.0.1", "10.0.0.1", "192.168.1.1", "172.16.0.1", "::1"] 85 + for ip_str in private_ips: 86 + ip = ipaddress.ip_address(ip_str) 87 + assert ip.is_private or ip.is_loopback or ip.is_link_local, f"{ip_str} should be blocked" 88 + 89 + def test_public_ips_allowed(self): 90 + public_ips = ["8.8.8.8", "1.1.1.1", "140.82.121.4"] 91 + for ip_str in public_ips: 92 + ip = ipaddress.ip_address(ip_str) 93 + assert not (ip.is_private or ip.is_loopback or ip.is_link_local), f"{ip_str} should be allowed"
+3 -3
tests/test_tool_usage.py
··· 31 31 32 32 # Create agent 33 33 agent = Agent( 34 - "anthropic:claude-3-5-haiku-latest", 34 + "anthropic:claude-haiku-4-5", 35 35 system_prompt="You are a helpful assistant. Use tools when asked.", 36 36 output_type=Response, 37 37 ) ··· 64 64 tool_calls: list[dict] = [] 65 65 66 66 agent = Agent( 67 - "anthropic:claude-3-5-haiku-latest", 67 + "anthropic:claude-haiku-4-5", 68 68 system_prompt="You help answer questions. Use search for current events.", 69 69 output_type=Response, 70 70 ) ··· 97 97 calls: list[str] = [] 98 98 99 99 agent = Agent( 100 - "anthropic:claude-3-5-haiku-latest", 100 + "anthropic:claude-haiku-4-5", 101 101 system_prompt="You are a helpful assistant.", 102 102 output_type=Response, 103 103 )
+40
uv.lock
··· 194 194 { name = "openai" }, 195 195 { name = "pydantic-ai" }, 196 196 { name = "pydantic-settings" }, 197 + { name = "slowapi" }, 197 198 { name = "turbopuffer" }, 198 199 { name = "uvicorn" }, 199 200 ] ··· 216 217 { name = "openai" }, 217 218 { name = "pydantic-ai" }, 218 219 { name = "pydantic-settings" }, 220 + { name = "slowapi", specifier = ">=0.1.9" }, 219 221 { name = "turbopuffer" }, 220 222 { name = "uvicorn" }, 221 223 ] ··· 431 433 sdist = { url = "https://files.pythonhosted.org/packages/30/ca/7782da3b03242d5f0a16c20371dff99d4bd1fedafe26bc48ff82e42be8c9/cyclopts-3.24.0.tar.gz", hash = "sha256:de6964a041dfb3c57bf043b41e68c43548227a17de1bad246e3a0bfc5c4b7417", size = 76131, upload-time = "2025-09-08T15:40:57.75Z" } 432 434 wheels = [ 433 435 { url = "https://files.pythonhosted.org/packages/f0/8b/2c95f0645c6f40211896375e6fa51f504b8ccb29c21f6ae661fe87ab044e/cyclopts-3.24.0-py3-none-any.whl", hash = "sha256:809d04cde9108617106091140c3964ee6fceb33cecdd537f7ffa360bde13ed71", size = 86154, upload-time = "2025-09-08T15:40:56.41Z" }, 436 + ] 437 + 438 + [[package]] 439 + name = "deprecated" 440 + version = "1.3.1" 441 + source = { registry = "https://pypi.org/simple" } 442 + dependencies = [ 443 + { name = "wrapt" }, 444 + ] 445 + sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } 446 + wheels = [ 447 + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, 434 448 ] 435 449 436 450 [[package]] ··· 1012 1026 { url = "https://files.pythonhosted.org/packages/80/af/aee0800b415b63dc5e259675c31a36d6c261afff8e288b56bc2867aa9310/libipld-3.1.1-cp313-cp313-win32.whl", hash = "sha256:5d34c40a27e8755f500277be5268a2f6b6f0d1e20599152d8a34cd34fb3f2700", size = 173730, upload-time = "2025-06-24T23:11:54.5Z" }, 1013 1027 { url = "https://files.pythonhosted.org/packages/54/a3/7e447f27ee896f48332254bb38e1b6c1d3f24b13e5029977646de9408159/libipld-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:5edee5f2ea8183bb6a151f149c9798a4f1db69fe16307e860a84f8d41b53665a", size = 179409, upload-time = "2025-06-24T23:11:55.356Z" }, 1014 1028 { url = "https://files.pythonhosted.org/packages/f2/0b/31d6097620c5cfaaaa0acb7760c29186029cd72c6ab81c537cc1ddfb34e5/libipld-3.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:7307876987d9e570dcaf17a15f0ba210f678b323860742d725cf6d8d8baeae1f", size = 169715, upload-time = "2025-06-24T23:11:56.41Z" }, 1029 + ] 1030 + 1031 + [[package]] 1032 + name = "limits" 1033 + version = "5.8.0" 1034 + source = { registry = "https://pypi.org/simple" } 1035 + dependencies = [ 1036 + { name = "deprecated" }, 1037 + { name = "packaging" }, 1038 + { name = "typing-extensions" }, 1039 + ] 1040 + sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" } 1041 + wheels = [ 1042 + { url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" }, 1015 1043 ] 1016 1044 1017 1045 [[package]] ··· 2233 2261 sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } 2234 2262 wheels = [ 2235 2263 { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, 2264 + ] 2265 + 2266 + [[package]] 2267 + name = "slowapi" 2268 + version = "0.1.9" 2269 + source = { registry = "https://pypi.org/simple" } 2270 + dependencies = [ 2271 + { name = "limits" }, 2272 + ] 2273 + sdist = { url = "https://files.pythonhosted.org/packages/a0/99/adfc7f94ca024736f061257d39118e1542bade7a52e86415a4c4ae92d8ff/slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77", size = 14028, upload-time = "2024-02-05T12:11:52.13Z" } 2274 + wheels = [ 2275 + { url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" }, 2236 2276 ] 2237 2277 2238 2278 [[package]]