From cc197b0c7e114dd86e68cf3541b9e2d8e5042c7d Mon Sep 17 00:00:00 2001 From: shoko <270575765+shokollm@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:27:54 +0000 Subject: [PATCH 01/10] feat(polymarket-browse): add --starts-before filter for match events - Add --starts-before CLI argument accepting Unix timestamp - Filter match events to only show those starting before timestamp - LIVE events are always included regardless of timestamp - Update SKILL.md documentation - Add TestStartsBeforeFilter with 3 unit tests --- skills/polymarket-browse/SKILL.md | 3 +- skills/polymarket-browse/scripts/browse.py | 53 ++++++++++- skills/polymarket-browse/tests/test_browse.py | 87 +++++++++++++++++++ 3 files changed, 141 insertions(+), 2 deletions(-) diff --git a/skills/polymarket-browse/SKILL.md b/skills/polymarket-browse/SKILL.md index 5924226..5e98a71 100644 --- a/skills/polymarket-browse/SKILL.md +++ b/skills/polymarket-browse/SKILL.md @@ -34,7 +34,7 @@ hermes mcp add polymarket https://docs.polymarket.com/mcp ## Usage ``` -polymarket-browse [--category "Counter Strike"] [--limit 5] [--matches N] [--non-matches N] [--search "TeamName"] [--matches-only] [--non-matches-only] [--detail N] [--raw] [--telegram] [--no-cache] [--max-total N] +polymarket-browse [--category "Counter Strike"] [--limit 5] [--matches N] [--non-matches N] [--search "TeamName"] [--matches-only] [--non-matches-only] [--detail N] [--raw] [--telegram] [--no-cache] [--max-total N] [--starts-before TIMESTAMP] ``` ## Arguments @@ -51,6 +51,7 @@ polymarket-browse [--category "Counter Strike"] [--limit 5] [--matches N] [--non - `--raw` : Show all events without tradeable filter (for debugging). Includes fetch stats. - `--no-cache` : Disable caching and fetch fresh data from the API. - `--max-total` : Maximum total events to fetch before early exit. Default: no limit. Useful for quick snapshots. +- `--starts-before` : Unix timestamp filter. Only show match events starting before this time (LIVE events always shown regardless of timestamp). - `--telegram` : Send results to Telegram. Requires `BOT_TOKEN` and `CHAT_ID` in environment variables. ## Output Format diff --git a/skills/polymarket-browse/scripts/browse.py b/skills/polymarket-browse/scripts/browse.py index e1af87b..027f633 100644 --- a/skills/polymarket-browse/scripts/browse.py +++ b/skills/polymarket-browse/scripts/browse.py @@ -544,6 +544,47 @@ def sort_events(events: list[dict[str, Any]]) -> list[dict[str, Any]]: # ============================================================ +def _is_live_event(e: dict[str, Any]) -> bool: + """Check if event is LIVE (started within last 4 hours).""" + start_str = e.get("startTime") or e.get("startDate", "") + if not start_str: + return False + try: + start_dt = datetime.fromisoformat(start_str.replace("Z", "+00:00")) + now = datetime.now(timezone.utc) + delta = now - start_dt + if delta.total_seconds() < 0: + return False + hours_ago = delta.total_seconds() / 3600 + return hours_ago < 4 + except Exception: + return False + + +def filter_by_starts_before( + events: list[dict[str, Any]], timestamp: int | None +) -> list[dict[str, Any]]: + """Filter events to only include those starting before timestamp or LIVE events.""" + if timestamp is None: + return events + filtered = [] + for e in events: + start_str = e.get("startTime") or e.get("startDate", "") + if not start_str: + filtered.append(e) + continue + try: + start_dt = datetime.fromisoformat(start_str.replace("Z", "+00:00")) + start_ts = start_dt.timestamp() + if start_ts <= timestamp: + filtered.append(e) + elif _is_live_event(e): + filtered.append(e) + except Exception: + filtered.append(e) + return filtered + + def browse_events( q: str, matches_max: int = 10, @@ -552,6 +593,7 @@ def browse_events( sort_by: str | None = None, max_total: int | None = None, use_cache: bool = True, + starts_before: int | None = None, ) -> BrowseResult: """ Browse Polymarket events. @@ -564,6 +606,7 @@ def browse_events( sort_by: None (fast, API order) or "volume" (full fetch, sort by volume desc) max_total: max total events to fetch before early exit (None = no limit) use_cache: whether to use cache (default True) + starts_before: unix timestamp filter for match events (None = no filter) """ use_early_exit = sort_by is None fetch_matches_max = matches_max if use_early_exit else None @@ -579,7 +622,8 @@ def browse_events( events = result["events"] match_events, non_match_events = filter_events(events, tradeable_only) - # Sort if requested; otherwise preserve API order + match_events = filter_by_starts_before(match_events, starts_before) + if sort_by == "volume": match_events = sort_events(match_events) non_match_events = sort_events(non_match_events) @@ -1174,6 +1218,12 @@ def main() -> None: default=None, help="Max total events to fetch before early exit. Default: no limit.", ) + parser.add_argument( + "--starts-before", + type=int, + default=None, + help="Unix timestamp filter. Only show match events starting before this time (LIVE events always shown).", + ) parser.add_argument( "--telegram", action="store_true", @@ -1205,6 +1255,7 @@ def main() -> None: tradeable_only=tradeable_only, max_total=args.max_total, use_cache=not args.no_cache, + starts_before=args.starts_before, ) print_browse( diff --git a/skills/polymarket-browse/tests/test_browse.py b/skills/polymarket-browse/tests/test_browse.py index 397c581..6d0ac00 100644 --- a/skills/polymarket-browse/tests/test_browse.py +++ b/skills/polymarket-browse/tests/test_browse.py @@ -1839,5 +1839,92 @@ class TestBrowseEvents(unittest.TestCase): self.assertIn("partial", result) +class TestStartsBeforeFilter(unittest.TestCase): + """Tests for --starts-before filter in browse_events().""" + + def _make_event(self, event_id, start_time, volume="50000"): + """Helper to create a minimal match event with startTime and valid tradeable data.""" + return { + "id": event_id, + "title": f"Match {event_id}", + "seriesSlug": "x", + "gameId": "1", + "startTime": start_time, + "markets": [ + { + "sportsMarketType": "moneyline", + "volume": volume, + "bestBid": "0.50", + "bestAsk": "0.52", + "acceptingOrders": True, + "closed": False, + } + ], + } + + @patch("browse.fetch_all_pages") + def test_starts_before_filters_future_events(self, mock_fetch): + """Events with startTime > timestamp should be filtered out.""" + from browse import browse_events + + mock_fetch.return_value = { + "events": [ + self._make_event( + "m1", "2026-03-27T14:00:00Z" + ), # After cutoff (14:00 > 12:00) + self._make_event("m2", "2026-03-28T12:00:00Z"), # After cutoff + ], + "total_raw": 2, + "partial": False, + } + + # 2026-03-27T12:00:00Z = 1774612800 + result = browse_events("test", starts_before=1774612800) + + self.assertEqual(len(result["match_events"]), 0) + + @patch("browse.fetch_all_pages") + def test_starts_before_includes_past_events(self, mock_fetch): + """Events with startTime <= timestamp should be included.""" + from browse import browse_events + + mock_fetch.return_value = { + "events": [ + self._make_event( + "m1", "2026-03-27T10:00:00Z" + ), # Before cutoff (10:00 < 12:00) + self._make_event( + "m2", "2026-03-27T11:00:00Z" + ), # Before cutoff (11:00 < 12:00) + ], + "total_raw": 2, + "partial": False, + } + + # 2026-03-27T12:00:00Z = 1774612800 + result = browse_events("test", starts_before=1774612800) + + self.assertEqual(len(result["match_events"]), 2) + + @patch("browse.fetch_all_pages") + def test_starts_before_without_timestamp(self, mock_fetch): + """Without starts_before, all events should be returned.""" + from browse import browse_events + + mock_fetch.return_value = { + "events": [ + self._make_event("m1", "2026-03-27T14:00:00Z"), + self._make_event("m2", "2026-03-28T12:00:00Z"), + ], + "total_raw": 2, + "partial": False, + } + + result = browse_events("test") + + # No filter, all events returned + self.assertEqual(len(result["match_events"]), 2) + + if __name__ == "__main__": unittest.main() From 0a1aab78839b369d67ce4d598c0f167c19da8d7c Mon Sep 17 00:00:00 2001 From: shoko <270575765+shokollm@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:07:59 +0000 Subject: [PATCH 02/10] fix(polymarket-browse): add --timezone CLI argument for display timezone - Add parse_timezone() function supporting UTC+X format - Add --timezone argument (default: UTC+7) - Module-level _DISPLAY_TZ controls all time display formatting - get_header_date() and _get_time_data() use _DISPLAY_TZ - Add TestTimezoneParsing unit tests - Update SKILL.md documentation --- skills/polymarket-browse/SKILL.md | 3 +- skills/polymarket-browse/scripts/browse.py | 59 +++++++++++++++++-- skills/polymarket-browse/tests/test_browse.py | 43 ++++++++++++++ 3 files changed, 98 insertions(+), 7 deletions(-) diff --git a/skills/polymarket-browse/SKILL.md b/skills/polymarket-browse/SKILL.md index aabfe28..9a8d278 100644 --- a/skills/polymarket-browse/SKILL.md +++ b/skills/polymarket-browse/SKILL.md @@ -35,7 +35,7 @@ hermes mcp add polymarket https://docs.polymarket.com/mcp ## Usage ``` -polymarket-browse [--category "Counter Strike"] [--limit 5] [--matches N] [--non-matches N] [--search "TeamName"] [--matches-only] [--non-matches-only] [--detail N] [--raw] [--telegram] [--no-cache] [--max-total N] +polymarket-browse [--category "Counter Strike"] [--limit 5] [--matches N] [--non-matches N] [--search "TeamName"] [--matches-only] [--non-matches-only] [--detail N] [--raw] [--telegram] [--no-cache] [--max-total N] [--timezone UTC+X] ``` ## Arguments @@ -52,6 +52,7 @@ polymarket-browse [--category "Counter Strike"] [--limit 5] [--matches N] [--non - `--raw` : Show all events without tradeable filter (for debugging). Includes fetch stats. - `--no-cache` : Disable caching and fetch fresh data from the API. - `--max-total` : Maximum total events to fetch before early exit. Default: no limit. Useful for quick snapshots. +- `--timezone` : Timezone for displaying times. Format: `UTC+X` or `UTC-X` (e.g., `UTC+7`, `UTC-5`). Default: UTC+7 (WIB). - `--telegram` : Send results to Telegram. Requires `BOT_TOKEN` and `CHAT_ID` in environment variables. ## Output Format diff --git a/skills/polymarket-browse/scripts/browse.py b/skills/polymarket-browse/scripts/browse.py index e1af87b..ad83519 100644 --- a/skills/polymarket-browse/scripts/browse.py +++ b/skills/polymarket-browse/scripts/browse.py @@ -98,6 +98,45 @@ PAGE_SIZE = 50 MAX_RETRIES = 5 INITIAL_RETRY_DELAY = 2 # exponential backoff starts at 2s WIB = timezone(timedelta(hours=7)) # UTC+7 for Indonesian users +_DISPLAY_TZ = WIB # Module-level timezone for display (configurable via --timezone) + + +def parse_timezone(tz_str: str) -> timezone: + """ + Parse timezone string to datetime.timezone. + Supports: UTC offset format (UTC+7, UTC-5). + Falls back to WIB (UTC+7) on parse failure. + """ + tz_str = tz_str.strip() + if tz_str.startswith("UTC"): + offset_str = tz_str[3:].strip() + if not offset_str: + return timezone.utc + sign = -1 if offset_str[0] == "-" else 1 + if offset_str[0] in "+-": + offset_str = offset_str[1:] + try: + if ":" in offset_str: + hours, minutes = offset_str.split(":") + hours = int(hours) + minutes = int(minutes) + else: + hours = int(offset_str) + minutes = 0 + total_minutes = hours * 60 + minutes + if sign == -1: + total_minutes = -total_minutes + return timezone(timedelta(minutes=total_minutes)) + except ValueError: + return WIB + return WIB + try: + from datetime import ZoneInfo + + return ZoneInfo(tz_str).utcoffset(None) + except Exception: + return WIB + GAME_CATEGORIES = { "All Esports": "Esports", @@ -453,12 +492,12 @@ def _get_time_data(e: dict[str, Any], tz: timezone | None = None) -> TimeData: Args: e: Event dict with 'startTime' or 'startDate' key. tz: datetime.timezone for abs_time formatting. - Defaults to WIB (UTC+7). + Defaults to _DISPLAY_TZ (set via --timezone, or WIB). Returns: TimeData with time_status, time_urgency, and abs_time """ - tz = tz or WIB + tz = tz or _DISPLAY_TZ start_str = e.get("startTime") or e.get("startDate", "") if not start_str: @@ -819,11 +858,10 @@ def format_detail_event(e: dict[str, Any]) -> DetailEvent: def get_header_date() -> str: - """Return current date string like 'Mar 25, 2026'""" + """Return current date string like 'Mar 25, 2026' in display timezone.""" now_utc = datetime.now(timezone.utc) - utc7 = timezone(timedelta(hours=7)) - now_utc7 = now_utc.astimezone(utc7) - return now_utc7.strftime("%b %d, %Y") + now_display = now_utc.astimezone(_DISPLAY_TZ) + return now_display.strftime("%b %d, %Y") def get_tournament(title: str) -> str: @@ -1174,6 +1212,12 @@ def main() -> None: default=None, help="Max total events to fetch before early exit. Default: no limit.", ) + parser.add_argument( + "--timezone", + type=str, + default="UTC+7", + help="Timezone for displaying times (e.g., UTC+7, UTC-5). Default: UTC+7", + ) parser.add_argument( "--telegram", action="store_true", @@ -1193,6 +1237,9 @@ def main() -> None: matches_max = args.matches if args.matches is not None else args.limit non_matches_max = args.non_matches if args.non_matches is not None else args.limit + global _DISPLAY_TZ + _DISPLAY_TZ = parse_timezone(args.timezone) + if args.search: print(f"\nFetching {args.category} events matching '{args.search}'...") else: diff --git a/skills/polymarket-browse/tests/test_browse.py b/skills/polymarket-browse/tests/test_browse.py index 397c581..adfd7b4 100644 --- a/skills/polymarket-browse/tests/test_browse.py +++ b/skills/polymarket-browse/tests/test_browse.py @@ -1839,5 +1839,48 @@ class TestBrowseEvents(unittest.TestCase): self.assertIn("partial", result) +class TestTimezoneParsing(unittest.TestCase): + """Tests for parse_timezone() and timezone display.""" + + def test_parse_timezone_utc_plus7(self): + """UTC+7 should parse to WIB.""" + from browse import parse_timezone + from datetime import timezone, timedelta + + tz = parse_timezone("UTC+7") + self.assertEqual(tz, timezone(timedelta(hours=7))) + + def test_parse_timezone_utc_minus5(self): + """UTC-5 should parse correctly.""" + from browse import parse_timezone + from datetime import timezone, timedelta + + tz = parse_timezone("UTC-5") + self.assertEqual(tz, timezone(timedelta(hours=-5))) + + def test_parse_timezone_utc_no_offset(self): + """UTC should return timezone.utc.""" + from browse import parse_timezone + + tz = parse_timezone("UTC") + self.assertEqual(tz, timezone.utc) + + def test_parse_timezone_with_minutes(self): + """UTC+5:30 should parse correctly.""" + from browse import parse_timezone + from datetime import timezone, timedelta + + tz = parse_timezone("UTC+5:30") + self.assertEqual(tz, timezone(timedelta(hours=5, minutes=30))) + + def test_parse_timezone_invalid_falls_back_to_wib(self): + """Invalid timezone should fall back to WIB.""" + from browse import parse_timezone + from datetime import timezone, timedelta + + tz = parse_timezone("Invalid/Timezone") + self.assertEqual(tz, timezone(timedelta(hours=7))) + + if __name__ == "__main__": unittest.main() From 8bd76f33012a0ac3d5ff57d97731c66b454c6991 Mon Sep 17 00:00:00 2001 From: shoko <270575765+shokollm@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:09:19 +0000 Subject: [PATCH 03/10] security(polymarket-browse): replace bare except: with specific exception handling - Lines 386, 400: except: changed to except (ValueError, TypeError): - ValueError: datetime.fromisoformat parse failure - TypeError: input is not a string - Prevents swallowing KeyboardInterrupt, SystemExit, MemoryError --- skills/polymarket-browse/scripts/browse.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/polymarket-browse/scripts/browse.py b/skills/polymarket-browse/scripts/browse.py index e1af87b..3616535 100644 --- a/skills/polymarket-browse/scripts/browse.py +++ b/skills/polymarket-browse/scripts/browse.py @@ -383,7 +383,7 @@ def is_tradeable_event(e: dict[str, Any]) -> bool: now = datetime.now(timezone.utc) if end_dt < now: return False - except: + except (ValueError, TypeError): pass # Filter: match has already started (startTime is in the past) @@ -397,7 +397,7 @@ def is_tradeable_event(e: dict[str, Any]) -> bool: hours_ago = (now - start_dt).total_seconds() / 3600 if hours_ago > 4: return False - except: + except (ValueError, TypeError): pass return True From 3928cdef7cd1f4abd2112887e0ec4fe67b6f0542 Mon Sep 17 00:00:00 2001 From: shoko <270575765+shokollm@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:10:40 +0000 Subject: [PATCH 04/10] security(polymarket-browse): validate --detail argument and show error if out of range - Add sys import for stderr/exit - Validate --detail index before accessing array - Show error with available range instead of silent fallback to first event - Exit with code 1 if --detail is out of range --- skills/polymarket-browse/scripts/browse.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/skills/polymarket-browse/scripts/browse.py b/skills/polymarket-browse/scripts/browse.py index e1af87b..8f7fb92 100644 --- a/skills/polymarket-browse/scripts/browse.py +++ b/skills/polymarket-browse/scripts/browse.py @@ -6,6 +6,7 @@ Browse tradeable Polymarket events by game category. import html import json +import sys import time import argparse import hashlib @@ -1224,10 +1225,15 @@ def main() -> None: # Print detail for selected event if any if result["match_events"] and args.detail > 0: - print("\n") idx = args.detail - 1 - if idx < 0 or idx >= len(result["match_events"]): - idx = 0 + num_events = len(result["match_events"]) + if idx < 0 or idx >= num_events: + print( + f"Error: --detail {args.detail} is out of range (available: 1-{num_events}).", + file=sys.stderr, + ) + sys.exit(1) + print("\n") detail_event = result["match_events"][idx] detail = format_detail_event(detail_event) print_detail(detail_event, detail) From bb7eebf502f0aa4c3045508def949c9c2124cdf1 Mon Sep 17 00:00:00 2001 From: shoko <270575765+shokollm@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:11:59 +0000 Subject: [PATCH 05/10] security(polymarket-browse): use proper URL encoding for --search parameter - Import quote from urllib.parse - Replace q.replace(' ', '%20') with quote(q, safe='') - Properly encodes: &, =, %, +, #, ?, and other special chars - Prevents URL injection attacks --- skills/polymarket-browse/scripts/browse.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/polymarket-browse/scripts/browse.py b/skills/polymarket-browse/scripts/browse.py index e1af87b..1cac50e 100644 --- a/skills/polymarket-browse/scripts/browse.py +++ b/skills/polymarket-browse/scripts/browse.py @@ -13,7 +13,7 @@ import os from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone, timedelta from typing import Any, Callable, TypedDict -from urllib.parse import urlencode +from urllib.parse import urlencode, quote from urllib.request import urlopen, Request @@ -166,7 +166,7 @@ def fetch_page( ) -> dict[str, Any] | None: base = "https://gamma-api.polymarket.com/public-search" url = ( - f"{base}?q={q.replace(' ', '%20')}&limit={PAGE_SIZE}&page={page}" + f"{base}?q={quote(q, safe='')}&limit={PAGE_SIZE}&page={page}" f"&search_profiles=false&search_tags=false" f"&keep_closed_markets=0&events_status=active&cache=false" ) From 36a7e8b3eb027f05181a2f1b4002a0ce45c54ae4 Mon Sep 17 00:00:00 2001 From: shoko <270575765+shokollm@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:13:13 +0000 Subject: [PATCH 06/10] security(polymarket-browse): add MAX_RESPONSE_SIZE limit to prevent memory exhaustion - Add MAX_RESPONSE_SIZE = 10MB constant - Check response size before json.loads() in fetch_page() - Raises ValueError if response exceeds limit - Prevents memory exhaustion from malicious/gigantic API responses --- skills/polymarket-browse/scripts/browse.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/skills/polymarket-browse/scripts/browse.py b/skills/polymarket-browse/scripts/browse.py index e1af87b..55e871c 100644 --- a/skills/polymarket-browse/scripts/browse.py +++ b/skills/polymarket-browse/scripts/browse.py @@ -97,6 +97,7 @@ class FetchResult(TypedDict): PAGE_SIZE = 50 MAX_RETRIES = 5 INITIAL_RETRY_DELAY = 2 # exponential backoff starts at 2s +MAX_RESPONSE_SIZE = 10 * 1024 * 1024 # 10MB limit per API response WIB = timezone(timedelta(hours=7)) # UTC+7 for Indonesian users GAME_CATEGORIES = { @@ -178,7 +179,12 @@ def fetch_page( try: req = Request(url, headers={"User-Agent": "Mozilla/5.0"}) with urlopen(req, timeout=10) as r: - return json.loads(r.read()) + data = r.read() + if len(data) > MAX_RESPONSE_SIZE: + raise ValueError( + f"API response too large: {len(data)} bytes (max {MAX_RESPONSE_SIZE})" + ) + return json.loads(data) except Exception: if attempt < max_retries - 1: delay *= 2 From c0484ab340983735ec1f1e052cbfb3e8252658b8 Mon Sep 17 00:00:00 2001 From: shoko <270575765+shokollm@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:15:02 +0000 Subject: [PATCH 07/10] security(polymarket-browse): add token bucket rate limiter for API calls - Add RateLimiter class (token bucket algorithm) - Thread-safe for use with ThreadPoolExecutor - RATE_LIMIT_CALLS = 10 per RATE_LIMIT_WINDOW = 1 second - _rate_limiter.acquire() called before each API request - Also add MAX_RESPONSE_SIZE check in fetch_page --- skills/polymarket-browse/scripts/browse.py | 44 +++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/skills/polymarket-browse/scripts/browse.py b/skills/polymarket-browse/scripts/browse.py index e1af87b..bd1623b 100644 --- a/skills/polymarket-browse/scripts/browse.py +++ b/skills/polymarket-browse/scripts/browse.py @@ -10,6 +10,7 @@ import time import argparse import hashlib import os +import threading from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone, timedelta from typing import Any, Callable, TypedDict @@ -97,8 +98,43 @@ class FetchResult(TypedDict): PAGE_SIZE = 50 MAX_RETRIES = 5 INITIAL_RETRY_DELAY = 2 # exponential backoff starts at 2s +MAX_RESPONSE_SIZE = 10 * 1024 * 1024 # 10MB limit per API response +RATE_LIMIT_CALLS = 10 # max API calls +RATE_LIMIT_WINDOW = 1.0 # per second WIB = timezone(timedelta(hours=7)) # UTC+7 for Indonesian users + +class RateLimiter: + """Token bucket rate limiter for API calls. Thread-safe for use with ThreadPoolExecutor.""" + + def __init__( + self, calls: int = RATE_LIMIT_CALLS, window: float = RATE_LIMIT_WINDOW + ): + self.calls = calls + self.window = window + self.tokens = float(calls) + self.last_update = time.monotonic() + self._lock = threading.Lock() + + def acquire(self) -> None: + """Block until a token is available.""" + with self._lock: + now = time.monotonic() + elapsed = now - self.last_update + self.tokens = min( + self.calls, self.tokens + elapsed * (self.calls / self.window) + ) + if self.tokens < 1: + wait_time = (1 - self.tokens) * (self.window / self.calls) + time.sleep(wait_time) + self.tokens = 0 + else: + self.tokens -= 1 + self.last_update = time.monotonic() + + +_rate_limiter = RateLimiter() + GAME_CATEGORIES = { "All Esports": "Esports", "Counter Strike": "Counter Strike", @@ -176,9 +212,15 @@ def fetch_page( if attempt > 0: time.sleep(delay) try: + _rate_limiter.acquire() req = Request(url, headers={"User-Agent": "Mozilla/5.0"}) with urlopen(req, timeout=10) as r: - return json.loads(r.read()) + data = r.read() + if len(data) > MAX_RESPONSE_SIZE: + raise ValueError( + f"API response too large: {len(data)} bytes (max {MAX_RESPONSE_SIZE})" + ) + return json.loads(data) except Exception: if attempt < max_retries - 1: delay *= 2 From 350fe17e872c033577b3a929ce6fbc1f5ac2c667 Mon Sep 17 00:00:00 2001 From: shoko <270575765+shokollm@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:17:16 +0000 Subject: [PATCH 08/10] docs(polymarket-browse): create SECURITY.md tracking audit findings - Document fixed security issues from 2026-03-25 audit - Track all 7 security issues and their fixes - Add reporting instructions --- skills/polymarket-browse/SECURITY.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 skills/polymarket-browse/SECURITY.md diff --git a/skills/polymarket-browse/SECURITY.md b/skills/polymarket-browse/SECURITY.md new file mode 100644 index 0000000..5927bbb --- /dev/null +++ b/skills/polymarket-browse/SECURITY.md @@ -0,0 +1,25 @@ +# Security Policy + +## Security Audit (2026-03-25) + +This document tracks security issues found during the 2026-03-25 audit. + +## Fixed Issues + +| Issue | Severity | Fixed Date | Fix | +|-------|----------|------------|-----| +| Telegram bot token in process command line | CRITICAL | 2026-03-25 | Switched to Python urlopen from curl subprocess | +| HTML injection in Telegram messages | HIGH | 2026-03-25 | Added escape_html() function | +| Insufficient --search URL encoding | MEDIUM | 2026-03-26 | Use urllib.parse.quote() | +| --detail bounds not validated | MEDIUM | 2026-03-26 | Error on out of range | +| No response size limits | MEDIUM | 2026-03-26 | MAX_RESPONSE_SIZE check | +| Bare except: clauses | LOW | 2026-03-26 | Catch specific exceptions | +| No API rate limiting | LOW | 2026-03-26 | TokenBucket rate limiter | + +## Open Issues + +All security issues from this audit have been addressed in subsequent releases. + +## Reporting Security Issues + +If you find a security vulnerability, please report it by opening an issue. From 3016d1287c79335dd63513830968b478a8bb1520 Mon Sep 17 00:00:00 2001 From: shoko <270575765+shokollm@users.noreply.github.com> Date: Fri, 27 Mar 2026 01:14:45 +0000 Subject: [PATCH 09/10] test(polymarket-browse): add URL encoding unit tests Add TestUrlEncoding class testing quote() encodes: - Space -> %20 - & -> %26 - = -> %3D - % -> %25 - + -> %2B - ( -> %28 - ) -> %29 - # -> %23 --- skills/polymarket-browse/tests/test_browse.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/skills/polymarket-browse/tests/test_browse.py b/skills/polymarket-browse/tests/test_browse.py index 397c581..a103434 100644 --- a/skills/polymarket-browse/tests/test_browse.py +++ b/skills/polymarket-browse/tests/test_browse.py @@ -1839,5 +1839,25 @@ class TestBrowseEvents(unittest.TestCase): self.assertIn("partial", result) +class TestUrlEncoding(unittest.TestCase): + """Tests for proper URL encoding of search queries.""" + + def test_quote_encodes_special_chars(self): + """quote() should properly encode all special characters.""" + from urllib.parse import quote + + test_cases = [ + ("Team A", "Team%20A"), + ("Team A & Team B", "Team%20A%20%26%20Team%20B"), + ("a=b", "a%3Db"), + ("100%", "100%25"), + ("C++", "C%2B%2B"), + ("Team (A)", "Team%20%28A%29"), + ("Team#1", "Team%231"), + ] + for input_str, expected in test_cases: + self.assertEqual(quote(input_str, safe=""), expected) + + if __name__ == "__main__": unittest.main() From 2c636048e7373f0ccde22c4d7dbf982d4c92c1bc Mon Sep 17 00:00:00 2001 From: shoko <270575765+shokollm@users.noreply.github.com> Date: Fri, 27 Mar 2026 01:36:32 +0000 Subject: [PATCH 10/10] security(polymarket-browse): improve response size limit with dynamic calculation - Replace fixed 10MB limit with dynamic calculation - get_max_response_size() computes limit based on PAGE_SIZE * multiplier - Uses 10x multiplier (e.g., PAGE_SIZE=50 -> ~500KB * 10 = ~5MB) - Clamped between 10MB minimum and 100MB maximum - Formula: max(PAGE_SIZE * multiplier, 10MB) capped at 100MB --- skills/polymarket-browse/scripts/browse.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/skills/polymarket-browse/scripts/browse.py b/skills/polymarket-browse/scripts/browse.py index 55e871c..e999f4b 100644 --- a/skills/polymarket-browse/scripts/browse.py +++ b/skills/polymarket-browse/scripts/browse.py @@ -97,9 +97,23 @@ class FetchResult(TypedDict): PAGE_SIZE = 50 MAX_RETRIES = 5 INITIAL_RETRY_DELAY = 2 # exponential backoff starts at 2s -MAX_RESPONSE_SIZE = 10 * 1024 * 1024 # 10MB limit per API response +MAX_RESPONSE_SIZE_MULTIPLIER = 10 # Response size limit = PAGE_SIZE * multiplier +MAX_RESPONSE_SIZE_MIN = 10 * 1024 * 1024 # 10MB minimum +MAX_RESPONSE_SIZE_MAX = 100 * 1024 * 1024 # 100MB maximum for safety WIB = timezone(timedelta(hours=7)) # UTC+7 for Indonesian users + +def get_max_response_size(page_size: int = PAGE_SIZE) -> int: + """ + Calculate max response size based on expected payload. + Uses 10x multiplier: if PAGE_SIZE=50 events, expected ~500KB-5MB, + so 10x gives 5MB-50MB. Clamped between 10MB and 100MB. + """ + multiplier = MAX_RESPONSE_SIZE_MULTIPLIER * page_size * 1024 # rough estimate + size = max(multiplier, MAX_RESPONSE_SIZE_MIN) + return min(size, MAX_RESPONSE_SIZE_MAX) + + GAME_CATEGORIES = { "All Esports": "Esports", "Counter Strike": "Counter Strike", @@ -180,9 +194,10 @@ def fetch_page( req = Request(url, headers={"User-Agent": "Mozilla/5.0"}) with urlopen(req, timeout=10) as r: data = r.read() - if len(data) > MAX_RESPONSE_SIZE: + max_size = get_max_response_size(PAGE_SIZE) + if len(data) > max_size: raise ValueError( - f"API response too large: {len(data)} bytes (max {MAX_RESPONSE_SIZE})" + f"API response too large: {len(data)} bytes (max {max_size})" ) return json.loads(data) except Exception: