diff --git a/src/agent_scan/cli.py b/src/agent_scan/cli.py index dce24b6..44eba52 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -13,7 +13,6 @@ import logging import sys -import aiohttp import psutil import rich from rich.logging import RichHandler @@ -23,7 +22,6 @@ from agent_scan.printer import print_scan_result from agent_scan.upload import get_hostname from agent_scan.utils import ensure_unicode_console, parse_headers, suppress_stdout -from agent_scan.verify_api import setup_aiohttp_debug_logging, setup_tcp_connector from agent_scan.version import version_info # Configure logging to suppress all output by default @@ -388,6 +386,70 @@ def main(): # use the same parser as scan setup_scan_parser(evo_parser) + # GUARD command + guard_parser = subparsers.add_parser( + "guard", + help="Install, uninstall, or check status of Agent Guard hooks", + description="Manage Agent Guard hooks for Claude Code and Cursor.", + ) + guard_subparsers = guard_parser.add_subparsers( + dest="guard_command", + title="Guard commands", + description="Available guard commands (default: show status)", + metavar="GUARD_COMMAND", + ) + + guard_install_parser = guard_subparsers.add_parser( + "install", + help="Install Agent Guard hooks for a client", + ) + guard_install_parser.add_argument( + "client", + choices=["claude", "cursor"], + help="Client to install hooks for", + ) + guard_install_parser.add_argument( + "--url", + type=str, + default="https://api.snyk.io", + help="Remote hooks base URL (default: https://api.snyk.io)", + ) + guard_install_parser.add_argument( + "--tenant-id", + type=str, + default=None, + dest="tenant_id", + help="Snyk tenant ID (required when minting a push key; not needed if PUSH_KEY is set)", + ) + guard_install_parser.add_argument( + "--test", + action="store_true", + default=False, + help="Send a test event to verify connectivity before installing hooks", + ) + guard_install_parser.add_argument( + "--file", + type=str, + default=None, + help="Override the config file path (default: client-specific well-known path)", + ) + + guard_uninstall_parser = guard_subparsers.add_parser( + "uninstall", + help="Remove Agent Guard hooks for a client", + ) + guard_uninstall_parser.add_argument( + "client", + choices=["claude", "cursor"], + help="Client to uninstall hooks from", + ) + guard_uninstall_parser.add_argument( + "--file", + type=str, + default=None, + help="Override the config file path (default: client-specific well-known path)", + ) + # Parse arguments (default to 'scan' if no command provided) if (len(sys.argv) == 1 or sys.argv[1] not in subparsers.choices) and ( not (len(sys.argv) == 2 and sys.argv[1] == "--help") @@ -431,6 +493,10 @@ def main(): elif args.command == "evo": asyncio.run(evo(args)) sys.exit(0) + elif args.command == "guard": + from agent_scan.guard import run_guard + + sys.exit(run_guard(args)) else: # This shouldn't happen due to argparse's handling @@ -447,44 +513,28 @@ async def evo(args): 2. Pushes scan results to the Evo API 3. Revokes the client_id """ + from agent_scan.pushkeys import mint_push_key, revoke_push_key + rich.print( - "Go to https://app.snyk.io and select the tenant on the left nav bar. Copy the Tenant ID from the URL and paste it here: " + "Go to https://app.snyk.io and select the tenant on the left nav bar. " + "Copy the Tenant ID from the URL and paste it here: " ) tenant_id = input().strip() rich.print("Paste the Authorization token from https://app.snyk.io/account (API Token -> KEY -> click to show): ") token = input().strip() - push_key_url = f"https://api.snyk.io/hidden/tenants/{tenant_id}/mcp-scan/push-key?version=2025-08-28" - push_scan_url = "https://api.snyk.io/hidden/mcp-scan/push?version=2025-08-28" + base_url = "https://api.snyk.io" + push_scan_url = f"{base_url}/hidden/mcp-scan/push?version=2025-08-28" - # create a client_id (shared secret) - client_id = None - skip_ssl_verify = getattr(args, "skip_ssl_verify", False) - trace_configs = setup_aiohttp_debug_logging(verbose=False) + # Mint a push key try: - async with aiohttp.ClientSession( - trace_configs=trace_configs, - connector=setup_tcp_connector(skip_ssl_verify=skip_ssl_verify), - trust_env=True, - ) as session: - async with session.post( - push_key_url, data="", headers={"Content-Type": "application/json", "Authorization": f"token {token}"} - ) as resp: - if resp.status not in (200, 201): - text = await resp.text() - rich.print(f"[bold red]Request failed[/bold red]: HTTP {resp.status} - {text}") - return - data = await resp.json() - client_id = data.get("client_id") - if not client_id: - rich.print(f"[bold red]Unexpected response[/bold red]: {data}") - return - rich.print("Client ID created") - except Exception as e: + client_id = mint_push_key(base_url, tenant_id, token) + rich.print("Client ID created") + except RuntimeError as e: rich.print(f"[bold red]Error calling Snyk API[/bold red]: {e}") return - # Update the default scan args + # Run scan with the push key args.control_servers = [ ControlServer( url=push_scan_url, @@ -494,24 +544,11 @@ async def evo(args): ] await run_scan(args, mode="scan") - # revoke the created client_id - del_headers = { - "Content-Type": "application/json", - "Authorization": f"token {token}", - "x-client-id": client_id, - } + # Revoke the push key try: - async with aiohttp.ClientSession( - trace_configs=trace_configs, - connector=setup_tcp_connector(skip_ssl_verify=skip_ssl_verify), - trust_env=True, - ) as session: - async with session.delete(push_key_url, headers=del_headers) as del_resp: - if del_resp.status not in (200, 204): - text = await del_resp.text() - rich.print(f"[bold red]Failed to revoke client_id[/bold red]: HTTP {del_resp.status} - {text}") - rich.print("Client ID revoked") - except Exception as e: + revoke_push_key(base_url, tenant_id, token, client_id) + rich.print("Client ID revoked") + except RuntimeError as e: rich.print(f"[bold red]Error revoking client_id[/bold red]: {e}") diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py new file mode 100644 index 0000000..e273542 --- /dev/null +++ b/src/agent_scan/guard.py @@ -0,0 +1,702 @@ +"""Agent Guard hook management for Claude Code and Cursor.""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import stat +import sys +from importlib import resources as importlib_resources +from pathlib import Path +from urllib.parse import urlparse + +import rich + +from agent_scan.pushkeys import mint_push_key, revoke_push_key + +IS_WINDOWS = sys.platform == "win32" + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +DEFAULT_REMOTE_URL = "https://api.snyk.io" +DETECTION_MARKER = "snyk-agent-guard" + +CLAUDE_SETTINGS_PATH = Path.home() / ".claude" / "settings.json" +CURSOR_HOOKS_PATH = Path.home() / ".cursor" / "hooks.json" + +CLAUDE_HOOK_EVENTS = [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "UserPromptSubmit", + "Stop", + "SessionStart", + "SessionEnd", + "SubagentStart", + "SubagentStop", +] +CLAUDE_EVENTS_WITH_MATCHER = {"PreToolUse", "PostToolUse", "PostToolUseFailure"} + +CURSOR_HOOK_EVENTS = [ + "beforeSubmitPrompt", + "beforeShellExecution", + "afterShellExecution", + "beforeMCPExecution", + "afterMCPExecution", + "beforeReadFile", + "afterFileEdit", + "stop", + "preToolUse", + "postToolUse", + "postToolUseFailure", + "sessionStart", + "sessionEnd", + "subagentStart", + "subagentStop", +] + +HOOK_VERSION = "2025-11-11" + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def run_guard(args) -> int: + try: + guard_command = getattr(args, "guard_command", None) + if guard_command == "install": + _run_install(args) + elif guard_command == "uninstall": + _run_uninstall(args) + else: + _run_status() + return 0 + except json.JSONDecodeError as e: + rich.print(f"[bold red]Error:[/bold red] Invalid JSON in config file: {e}") + return 1 + except PermissionError as e: + rich.print(f"[bold red]Error:[/bold red] Permission denied: {e}") + return 1 + + +# --------------------------------------------------------------------------- +# Install +# --------------------------------------------------------------------------- + + +def _get_machine_description(client: str) -> str: + from agent_scan.upload import get_hostname + + hostname = get_hostname() + label = _client_label(client) + return f"agent-guard ({hostname}) {label}" + + +def _run_install(args) -> None: + client: str = args.client + url: str = args.url + push_key = os.environ.get("PUSH_KEY", "") + headless = bool(push_key) + tenant_id: str = getattr(args, "tenant_id", None) or "" + + label = _client_label(client) + snyk_token = "" + + if not headless: + # Interactive flow — mint a push key + rich.print(f"Installing [bold magenta]Agent Guard[/bold magenta] hooks for [bold]{label}[/bold]") + rich.print() + + snyk_token = os.environ.get("SNYK_TOKEN", "") + if not snyk_token: + rich.print("Paste your Snyk API token ( from https://app.snyk.io/account ):") + snyk_token = input().strip() + if not snyk_token: + rich.print("[bold red]Error:[/bold red] SNYK_TOKEN is required to mint a push key.") + sys.exit(1) + + if not tenant_id: + tenant_id = os.environ.get("TENANT_ID", "") + if not tenant_id: + rich.print("Enter your Snyk Tenant ID ( from the URL at https://app.snyk.io ):") + tenant_id = input().strip() + if not tenant_id: + rich.print("[bold red]Error:[/bold red] Tenant ID is required to mint a push key.") + sys.exit(1) + + description = _get_machine_description(client) + rich.print(f"[dim]Minting push key for {description}...[/dim]") + try: + push_key = mint_push_key(url, tenant_id, snyk_token, description=description) + except RuntimeError as e: + rich.print(f"[bold red]Error:[/bold red] {e}") + if "403" in str(e): + rich.print( + f"[yellow]Please ensure you have access to tenant [bold]{tenant_id}[/bold] and access to Evo Agent Guard.[/yellow]" + ) + sys.exit(1) + rich.print(f"[green]\u2713[/green] Push key minted [yellow]{_mask_key(push_key)}[/yellow]") + + hook_client = "claude-code" if client == "claude" else "cursor" + minted = not headless # True if we minted the key in this run + config_path = _config_path(client, getattr(args, "file", None)) + # Copy hook script first so we can use it for the test event + dest_path, script_existed, script_updated = _copy_hook_script(client, config_path) + + first_install = not config_path.exists() or not script_existed + run_test = first_install or minted or getattr(args, "test", False) + + # Verify connectivity by invoking the actual hook script + if run_test and not _send_test_event(push_key, url, hook_client, dest_path): + # Clean up copied script only if it didn't exist before + if not script_existed: + dest_path.unlink(missing_ok=True) + if minted: + rich.print("[dim]Revoking minted push key...[/dim]") + try: + revoke_push_key(url, tenant_id, snyk_token, push_key) + rich.print("[green]\u2713[/green] Push key revoked") + except RuntimeError as e: + rich.print(f"[yellow]Warning:[/yellow] Could not revoke push key: {e}") + rich.print("[bold red]Aborting install — test event failed.[/bold red]") + raise SystemExit(1) + + # Build command string and edit client config + command = _build_hook_command(push_key, url, dest_path, hook_client, tenant_id=tenant_id) + + if client == "claude": + config_changed = _install_claude(command, config_path) + elif client == "cursor": + config_changed = _install_cursor(command, config_path) + + if script_updated or config_changed or minted: + rich.print(f"[green]\u2713[/green] Hooks installed for [bold]{label}[/bold]") + else: + rich.print(f"[green]\u2713[/green] {label} hook integration up to date") + rich.print(f" Config: [dim]{config_path}[/dim]") + rich.print(f" Script: [dim]{dest_path}[/dim]") + rich.print(f" Remote URL: [dim]{url}[/dim]") + rich.print(f" Push Key: [yellow]{_mask_key(push_key)}[/yellow]") + rich.print() + + +def _install_claude(command: str, path: Path) -> bool: + """Install Claude hooks. Returns True if the file was changed.""" + settings = _read_json_or_empty(path) + hooks = settings.get("hooks", {}) + + preserved = _count_non_agent_scan_claude(hooks) + hooks = _filter_claude_hooks(hooks) + + for event in CLAUDE_HOOK_EVENTS: + entry = {"type": "command", "command": command} + if IS_WINDOWS: + entry["shell"] = "powershell" + group: dict = {"hooks": [entry]} + if event in CLAUDE_EVENTS_WITH_MATCHER: + group["matcher"] = "*" + existing = hooks.get(event, []) + existing.append(group) + hooks[event] = existing + + settings["hooks"] = hooks + + if not _write_json_if_changed(path, settings): + return False + note = _preserved_note(preserved) + rich.print(f"[green]\u2713[/green] Written [dim]{path}[/dim]{note}") + return True + + +def _install_cursor(command: str, path: Path) -> bool: + """Install Cursor hooks. Returns True if the file was changed.""" + data = _read_json_or_empty(path) + if "version" not in data: + data["version"] = 1 + hooks = data.get("hooks", {}) + + preserved = _count_non_agent_scan_cursor(hooks) + hooks = _filter_cursor_hooks(hooks) + + for event in CURSOR_HOOK_EVENTS: + existing = hooks.get(event, []) + existing.append({"command": command}) + hooks[event] = existing + + data["hooks"] = hooks + + if not _write_json_if_changed(path, data): + return False + note = _preserved_note(preserved) + rich.print(f"[green]\u2713[/green] Written [dim]{path}[/dim]{note}") + return True + + +# --------------------------------------------------------------------------- +# Uninstall +# --------------------------------------------------------------------------- + + +def _run_uninstall(args) -> None: + client: str = args.client + label = _client_label(client) + config_path = _config_path(client, getattr(args, "file", None)) + + rich.print(f"Removing [bold magenta]Agent Guard[/bold magenta] hooks from [bold]{label}[/bold]") + rich.print("[dim]Other hooks in the file will be preserved.[/dim]") + rich.print() + + # Detect the installed command to extract push key + tenant for revocation + info = _detect_claude_install(config_path) if client == "claude" else _detect_cursor_install(config_path) + + # Remove hooks from config + if client == "claude": + _uninstall_claude(config_path) + elif client == "cursor": + _uninstall_cursor(config_path) + + # Remove hook script + _remove_hook_script(client, config_path) + + # Try to revoke the push key + if info and info.get("auth_value"): + _try_revoke_push_key(info, label) + + rich.print() + + +def _try_revoke_push_key(info: dict, label: str) -> None: + push_key = info.get("auth_value", "") + tenant_id = info.get("tenant_id", "") + url = info.get("url", DEFAULT_REMOTE_URL) + snyk_token = os.environ.get("SNYK_TOKEN", "") + + if not tenant_id or not snyk_token: + rich.print( + f"[dim] Push key {_mask_key(push_key)} was not revoked (set SNYK_TOKEN to revoke on uninstall).[/dim]" + ) + return + + try: + revoke_push_key(url, tenant_id, snyk_token, push_key) + rich.print(f"[green]\u2713[/green] Push key {_mask_key(push_key)} revoked") + except RuntimeError as e: + rich.print(f"[yellow]Warning:[/yellow] Could not revoke push key: {e}") + + +def _uninstall_claude(path: Path) -> None: + if not path.exists(): + rich.print("[dim]No settings.json found. Nothing to uninstall.[/dim]") + return + + settings = _read_json_or_empty(path) + hooks = settings.get("hooks", {}) + + total_before = sum(len(groups) for groups in hooks.values()) + filtered = _filter_claude_hooks(hooks) + total_after = sum(len(groups) for groups in filtered.values()) + + removed = total_before - total_after + if removed == 0: + rich.print("[dim]No Agent Guard hooks found.[/dim]") + return + + _backup_file(path) + if filtered: + settings["hooks"] = filtered + else: + settings.pop("hooks", None) + _write_json(path, settings) + rich.print(f"[green]\u2713[/green] Removed {removed} Agent Guard hook(s){_preserved_note(total_after)}") + + +def _uninstall_cursor(path: Path) -> None: + if not path.exists(): + rich.print("[dim]No hooks.json found. Nothing to uninstall.[/dim]") + return + + data = _read_json_or_empty(path) + hooks = data.get("hooks", {}) + + total_before = sum(len(entries) for entries in hooks.values()) + filtered = _filter_cursor_hooks(hooks) + total_after = sum(len(entries) for entries in filtered.values()) + + removed = total_before - total_after + if removed == 0: + rich.print("[dim]No Agent Guard hooks found.[/dim]") + return + + _backup_file(path) + data["hooks"] = filtered + _write_json(path, data) + rich.print(f"[green]\u2713[/green] Removed {removed} Agent Guard hook(s){_preserved_note(total_after)}") + + +# --------------------------------------------------------------------------- +# Status +# --------------------------------------------------------------------------- + + +def _run_status() -> None: + _print_client_status("Claude Code", CLAUDE_SETTINGS_PATH, _detect_claude_install()) + rich.print() + _print_client_status("Cursor", CURSOR_HOOKS_PATH, _detect_cursor_install()) + rich.print() + rich.print("[dim]# interactive flow[/dim]") + rich.print("[dim]snyk-agent-scan guard install [/dim]") + rich.print() + rich.print("[dim]# headless flow (MDM)[/dim]") + rich.print("[dim]PUSH_KEY= snyk-agent-scan guard install [/dim]") + rich.print() + rich.print( + "[dim]If hooks are already installed and up to date, install commands are no-ops. To uninstall use 'snyk-agent-scan guard uninstall '[/dim]" + ) + + +def _print_client_status(label: str, path: Path, info: dict | None) -> None: + rich.print(f"[bold white]{label}[/bold white] [dim]{path}[/dim]") + if info is None: + rich.print(" [dim]NOT INSTALLED[/dim]") + return + + auth_label = f"[yellow]\\[Push Key: {_mask_key(info['auth_value'])}][/yellow]" + hooks_suffix = _compact_events(info["events"]) + rich.print( + f" [bold green]INSTALLED[/bold green] " + f"[bold white]\\[{info['host']}][/bold white] " + f"{auth_label} " + f"[dim]{hooks_suffix}[/dim]" + ) + + +def _detect_claude_install(path: Path = CLAUDE_SETTINGS_PATH) -> dict | None: + if not path.exists(): + return None + settings = _read_json_or_empty(path) + hooks = settings.get("hooks", {}) + + events = [] + found_cmd = None + for event in CLAUDE_HOOK_EVENTS: + for group in hooks.get(event, []): + for h in group.get("hooks", []): + if _is_agent_scan_command(h.get("command", "")): + events.append(event) + if found_cmd is None: + found_cmd = h["command"] + break + else: + continue + break + + if not events or found_cmd is None: + return None + return _parse_command_info(found_cmd, events) + + +def _detect_cursor_install(path: Path = CURSOR_HOOKS_PATH) -> dict | None: + if not path.exists(): + return None + data = _read_json_or_empty(path) + hooks = data.get("hooks", {}) + + events = [] + found_cmd = None + for event in CURSOR_HOOK_EVENTS: + for entry in hooks.get(event, []): + if _is_agent_scan_command(entry.get("command", "")): + events.append(event) + if found_cmd is None: + found_cmd = entry["command"] + break + + if not events or found_cmd is None: + return None + return _parse_command_info(found_cmd, events) + + +# --------------------------------------------------------------------------- +# Test event +# --------------------------------------------------------------------------- + + +def _send_test_event(push_key: str, url: str, hook_client: str, script_path: Path) -> bool: + """Send a test hooksConfigured event by invoking the hook script. Returns True on success.""" + import subprocess + + if hook_client == "claude-code": + payload = '{"hook_event_name":"hooksConfigured","session_id":"hooks-setup"}' + else: + payload = '{"hook_event_name":"hooksConfigured","conversation_id":"hooks-setup"}' + + if IS_WINDOWS: + cmd = [ + "powershell", + "-File", + str(script_path), + "-Client", + hook_client, + "-PushKey", + push_key, + "-RemoteUrl", + url, + ] + env = None # inherit current env + else: + cmd = ["bash", str(script_path), "--client", hook_client] + env = { + **os.environ, + "PUSH_KEY": push_key, + "REMOTE_HOOKS_BASE_URL": url, + } + + try: + result = subprocess.run( + cmd, + input=payload, + capture_output=True, + text=True, + timeout=15, + env=env, + ) + if result.returncode == 0: + rich.print("[green]\u2713[/green] Test event sent [green]\u2192 OK[/green]") + return True + stderr = result.stderr.strip() + rich.print(f"[red]\u2717[/red] Test event failed: {stderr or f'exit code {result.returncode}'}") + return False + except subprocess.TimeoutExpired: + rich.print("[red]\u2717[/red] Test event failed: timeout") + return False + except Exception as e: + rich.print(f"[red]\u2717[/red] Test event failed: {e}") + return False + + +# --------------------------------------------------------------------------- +# Detection / filtering +# --------------------------------------------------------------------------- + + +def _is_agent_scan_command(cmd: str) -> bool: + return DETECTION_MARKER in cmd + + +def _filter_claude_hooks(hooks: dict) -> dict: + result = {} + for event, groups in hooks.items(): + filtered = [ + g for g in groups if not any(_is_agent_scan_command(h.get("command", "")) for h in g.get("hooks", [])) + ] + if filtered: + result[event] = filtered + return result + + +def _filter_cursor_hooks(hooks: dict) -> dict: + result = {} + for event, entries in hooks.items(): + filtered = [e for e in entries if not _is_agent_scan_command(e.get("command", ""))] + if filtered: + result[event] = filtered + return result + + +def _count_non_agent_scan_claude(hooks: dict) -> int: + n = 0 + for groups in hooks.values(): + for g in groups: + if not any(_is_agent_scan_command(h.get("command", "")) for h in g.get("hooks", [])): + n += 1 + return n + + +def _count_non_agent_scan_cursor(hooks: dict) -> int: + n = 0 + for entries in hooks.values(): + for e in entries: + if not _is_agent_scan_command(e.get("command", "")): + n += 1 + return n + + +# --------------------------------------------------------------------------- +# Command parsing +# --------------------------------------------------------------------------- + + +def _parse_command_info(cmd: str, events: list[str]) -> dict: + url = _extract_env_from_cmd(cmd, "REMOTE_HOOKS_BASE_URL") + push_key = _extract_env_from_cmd(cmd, "PUSH_KEY") + tenant_id = _extract_env_from_cmd(cmd, "TENANT_ID") + host = urlparse(url).netloc if url else "unknown" + + return { + "host": host, + "auth_type": "pushkey", + "auth_value": push_key or "", + "tenant_id": tenant_id, + "url": url or DEFAULT_REMOTE_URL, + "events": events, + } + + +_PS_PARAM_MAP = { + "PUSH_KEY": "PushKey", + "REMOTE_HOOKS_BASE_URL": "RemoteUrl", +} + + +def _extract_env_from_cmd(cmd: str, key: str) -> str: + # Try PowerShell -ParamName 'value' form + ps_name = _PS_PARAM_MAP.get(key) + if ps_name: + m = re.search(rf"-{re.escape(ps_name)}\s+'([^']*)'", cmd) + if m: + return m.group(1) + # Try KEY='...' form + m = re.search(rf"(?:^| ){re.escape(key)}='([^']*)'", cmd) + if m: + return m.group(1) + # Try KEY=value (no quotes) + m = re.search(rf"(?:^| ){re.escape(key)}=(\S+)", cmd) + if m: + return m.group(1) + return "" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _client_label(client: str) -> str: + return "Claude Code" if client == "claude" else "Cursor" + + +def _config_path(client: str, override: str | None = None) -> Path: + """Resolve the config file path for a client, with optional override.""" + if override: + return Path(override) + return CLAUDE_SETTINGS_PATH if client == "claude" else CURSOR_HOOKS_PATH + + +def _build_hook_command(push_key: str, url: str, script_path: Path, hook_client: str, *, tenant_id: str = "") -> str: + if IS_WINDOWS: + return _build_hook_command_powershell(push_key, url, script_path, hook_client, tenant_id=tenant_id) + parts = [ + f"PUSH_KEY={_shell_quote(push_key)}", + f"REMOTE_HOOKS_BASE_URL={_shell_quote(url)}", + ] + if tenant_id: + parts.append(f"TENANT_ID={_shell_quote(tenant_id)}") + parts.append(f"bash {_shell_quote(script_path.as_posix())}") + parts.append(f"--client {hook_client}") + return " ".join(parts) + + +def _build_hook_command_powershell( + push_key: str, url: str, script_path: Path, hook_client: str, *, tenant_id: str = "" +) -> str: + return f"powershell -File '{script_path}' -Client {hook_client} -PushKey '{push_key}' -RemoteUrl '{url}'" + + +def _shell_quote(s: str) -> str: + return "'" + s.replace("'", "'\"'\"'") + "'" + + +def _mask_key(k: str) -> str: + if len(k) <= 8: + return k + return k[:4] + "..." + k[-4:] + + +def _compact_events(events: list[str]) -> str: + if not events: + return "(no hooks)" + show = 2 + if len(events) <= show: + return "(" + ", ".join(events) + ")" + return f"({', '.join(events[:show])} + {len(events) - show} more)" + + +def _copy_hook_script(client: str, config_path: Path) -> tuple[Path, bool, bool]: + """Copy bundled hook script to a hooks/ dir next to the config file. + + Returns (path, already_existed, was_updated). + """ + dest_dir = config_path.parent / "hooks" + + dest_dir.mkdir(parents=True, exist_ok=True) + script_name = "snyk-agent-guard.ps1" if IS_WINDOWS else "snyk-agent-guard.sh" + dest = dest_dir / script_name + existed = dest.exists() + + from agent_scan.version import version_info + + hook_pkg = importlib_resources.files("agent_scan.hooks") + source = hook_pkg.joinpath(script_name) + new_content = source.read_bytes().replace(b"__AGENT_SCAN_VERSION__", version_info.encode()) + + if existed and dest.read_bytes() == new_content: + return dest, existed, False + + dest.write_bytes(new_content) + dest.chmod(dest.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + rich.print(f"[green]\u2713[/green] Copied hook script to [dim]{dest}[/dim]") + return dest, existed, True + + +def _remove_hook_script(client: str, config_path: Path) -> None: + dest_dir = config_path.parent / "hooks" + script_name = "snyk-agent-guard.ps1" if IS_WINDOWS else "snyk-agent-guard.sh" + dest = dest_dir / script_name + if dest.exists(): + dest.unlink() + rich.print(f"[green]\u2713[/green] Removed hook script [dim]{dest}[/dim]") + + +def _backup_file(path: Path) -> None: + if path.exists(): + backup = Path(str(path) + ".backup") + shutil.copy2(path, backup) + rich.print(f"[green]\u2713[/green] Backed up [dim]{path}[/dim] \u2192 [dim]{backup}[/dim]") + + +def _read_json_or_empty(path: Path) -> dict: + if not path.exists(): + return {} + with open(path) as f: + return json.load(f) + + +def _write_json_if_changed(path: Path, data: dict) -> bool: + """Write JSON to path only if content differs. Backs up before writing. Returns True if written.""" + new_content = json.dumps(data, indent=2) + "\n" + if path.exists(): + old_content = path.read_text() + if old_content == new_content: + return False + _backup_file(path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(new_content) + return True + + +def _write_json(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w") as f: + json.dump(data, f, indent=2) + f.write("\n") + + +def _preserved_note(count: int) -> str: + if count == 0: + return "" + return f" [dim]({count} other hook(s) preserved)[/dim]" diff --git a/src/agent_scan/hooks/__init__.py b/src/agent_scan/hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agent_scan/hooks/snyk-agent-guard.ps1 b/src/agent_scan/hooks/snyk-agent-guard.ps1 new file mode 100644 index 0000000..0ef81fa --- /dev/null +++ b/src/agent_scan/hooks/snyk-agent-guard.ps1 @@ -0,0 +1,113 @@ +# +# Thin-client hook handler for forwarding agent hook events to Evo Agent Guard. +# Supports both Claude Code and Cursor via the -Client argument. +# +# Usage: +# powershell -File snyk-agent-guard.ps1 -Client claude-code -PushKey '...' -RemoteUrl 'https://...' +# +# Reads a JSON payload from stdin and POSTs it (base64-encoded) to the Agent Guard endpoint. +# +# Requirements: PowerShell 5.1+ (built-in on Windows 10+) +# +param( + [Parameter(Mandatory=$true)] + [ValidateSet("claude-code","cursor")] + [string]$Client, + + [Parameter(Mandatory=$false)] + [string]$PushKey, + + [Parameter(Mandatory=$false)] + [string]$RemoteUrl +) + +$ErrorActionPreference = "Stop" + +# Hook API version. +$VERSION = "2025-11-11" + +# Agent-scan CLI version (replaced at install time). +$AGENT_SCAN_VERSION = "__AGENT_SCAN_VERSION__" + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +# Parameters take precedence over env vars. +if (-not $PushKey) { $PushKey = if ($env:PUSH_KEY) { $env:PUSH_KEY } elseif ($env:PUSHKEY) { $env:PUSHKEY } else { $null } } +if (-not $PushKey) { + Write-Error "PUSH_KEY is required (pass -PushKey or set env var)" + exit 1 +} + +if (-not $RemoteUrl) { $RemoteUrl = $env:REMOTE_HOOKS_BASE_URL } +if (-not $RemoteUrl) { + Write-Error "REMOTE_HOOKS_BASE_URL is required (pass -RemoteUrl or set env var)" + exit 1 +} + +switch ($Client) { + "claude-code" { + $endpoint = "/hidden/agent-monitor/hooks/claude-code" + } + "cursor" { + $endpoint = "/hidden/agent-monitor/hooks/cursor" + } +} + +$userAgent = "snyk/snyk-agent-guard.ps1 Agent Scan v$AGENT_SCAN_VERSION" +$url = "${RemoteUrl}${endpoint}?version=$VERSION" + +# Read payload from stdin as UTF-8 (strips BOM automatically) +$reader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8, $true) +$payload = $reader.ReadToEnd().Trim() +if (-not $payload) { + Write-Error "Expected JSON payload on stdin" + exit 1 +} + +# Base64 encode +$bytes = [System.Text.Encoding]::UTF8.GetBytes($payload) +$encoded = [System.Convert]::ToBase64String($bytes) +$body = "base64:$encoded" + +# Build X-User header +$hostname = try { [System.Net.Dns]::GetHostName() } catch { "unknown" } +$username = try { [System.Environment]::UserName } catch { "unknown" } + +# Minimal JSON escaping +function JsonEscape($s) { + $s = $s -replace '\\', '\\\\' + $s = $s -replace '"', '\"' + $s = $s -replace "`t", '\t' + $s = $s -replace "`r", '\r' + $s = $s -replace "`n", '\n' + return $s +} + +$xUser = '{{"hostname":"{0}","username":"{1}","identifier":"{2}"}}' -f ` + (JsonEscape $hostname), (JsonEscape $username), (JsonEscape $hostname) + +# Execute request +try { + $headers = @{ + "User-Agent" = $userAgent + "X-User" = $xUser + "Content-Type" = "text/plain" + "X-Client-Id" = $PushKey + } + $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($body) + $response = Invoke-WebRequest -Uri $url -Method POST -Body $bodyBytes -Headers $headers -UseBasicParsing + Write-Output $response.Content +} catch { + $statusCode = $null + if ($_.Exception.Response) { + $statusCode = [int]$_.Exception.Response.StatusCode + } + if ($statusCode) { + Write-Error "Error: $statusCode" + } else { + Write-Error "Request failed: $_" + } + exit 1 +} diff --git a/src/agent_scan/hooks/snyk-agent-guard.sh b/src/agent_scan/hooks/snyk-agent-guard.sh new file mode 100755 index 0000000..bc32f16 --- /dev/null +++ b/src/agent_scan/hooks/snyk-agent-guard.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# +# Thin-client hook handler for forwarding agent hook events to Evo Agent Guard. +# Supports both Claude Code and Cursor via the --client argument. +# +# Usage: +# PUSH_KEY='...' REMOTE_HOOKS_BASE_URL='...' bash snyk-agent-guard.sh --client claude-code +# PUSH_KEY='...' REMOTE_HOOKS_BASE_URL='...' bash snyk-agent-guard.sh --client cursor +# +# Reads a JSON payload from stdin and POSTs it (base64-encoded) to the Agent Guard endpoint. +# +# Requirements: bash, curl, base64, tr +# +set -euo pipefail + +# Hook API version. +VERSION="2025-11-11" + +# Agent-scan CLI version (replaced at install time). +AGENT_SCAN_VERSION="__AGENT_SCAN_VERSION__" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +die() { + echo "Error: $*" 1>&2 + exit 1 +} + +json_escape() { + local s="${1-}" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\t'/\\t}" + s="${s//$'\r'/\\r}" + s="${s//$'\n'/\\n}" + printf '%s' "$s" +} + +json_quote() { + printf '"%s"' "$(json_escape "${1:-}")" +} + +get_hostname() { + if [[ -n "${HOSTNAME:-}" ]]; then + printf '%s' "$HOSTNAME" + return + fi + if command -v uname >/dev/null 2>&1; then + uname -n 2>/dev/null && return + fi + if command -v hostname >/dev/null 2>&1; then + hostname 2>/dev/null && return + fi + printf '%s' "unknown" +} + +get_username() { + if command -v id >/dev/null 2>&1; then + id -un 2>/dev/null && return + fi + if command -v whoami >/dev/null 2>&1; then + whoami 2>/dev/null && return + fi + printf '%s' "unknown" +} + +# --------------------------------------------------------------------------- +# Main hook logic +# --------------------------------------------------------------------------- + +hook_main() { + local client="" + + # Parse arguments + while [[ $# -gt 0 ]]; do + case "$1" in + --client) client="${2:-}"; shift 2 ;; + *) shift ;; + esac + done + + [[ -n "$client" ]] || die "Missing required argument: --client " + [[ -n "${REMOTE_HOOKS_BASE_URL:-}" ]] || die "REMOTE_HOOKS_BASE_URL environment variable is not set" + + local pushkey + pushkey="${PUSH_KEY:-${PUSHKEY:-}}" + [[ -n "$pushkey" ]] || die "PUSH_KEY environment variable is not set" + + # Determine endpoint and user-agent based on client + local endpoint user_agent + case "$client" in + claude-code) + endpoint="/hidden/agent-monitor/hooks/claude-code" + user_agent="snyk/snyk-agent-guard.sh Agent Scan v${AGENT_SCAN_VERSION}" + ;; + cursor) + endpoint="/hidden/agent-monitor/hooks/cursor" + user_agent="snyk/snyk-agent-guard.sh Agent Scan v${AGENT_SCAN_VERSION}" + ;; + *) die "Unknown client: ${client}. Expected claude-code or cursor." ;; + esac + + local url="${REMOTE_HOOKS_BASE_URL}${endpoint}?version=${VERSION}" + + # Read payload from stdin + local payload + payload="$(cat)" + [[ -n "$payload" ]] || die "Expected JSON payload on stdin" + + command -v base64 >/dev/null 2>&1 || die "Missing required dependency: base64" + command -v curl >/dev/null 2>&1 || die "Missing required dependency: curl" + + # Base64 encode + local encoded_body + encoded_body="base64:$(printf '%s' "$payload" | base64 | tr -d '\n')" + + # Build X-User header + local hostname username x_user + hostname="$(get_hostname)" + username="$(get_username)" + + x_user="$(printf '{%s:%s,%s:%s,%s:%s}' \ + "\"hostname\"" "$(json_quote "$hostname")" \ + "\"username\"" "$(json_quote "$username")" \ + "\"identifier\"" "$(json_quote "$hostname")")" + + # Execute request + local resp body http_code marker + marker="__SNYK_AGENT_SCAN_HOOK_HTTP_CODE__=" + + local -a curl_args + curl_args=( + -sS + -X POST + "$url" + -H "User-Agent: ${user_agent}" + -H "X-User: ${x_user}" + -H "Content-Type: text/plain" + -H "X-Client-Id: ${pushkey}" + --data-binary "${encoded_body}" + ) + + resp="$(curl "${curl_args[@]}" -w $'\n'"${marker}%{http_code}")" || die "Request failed" + http_code="${resp##*$'\n'"${marker}"}" + body="${resp%$'\n'"${marker}"*}" + + if [[ "$http_code" =~ ^[0-9]{3}$ ]] && (( http_code >= 400 )); then + echo "Error: ${http_code}" 1>&2 + exit 1 + fi + + printf '%s' "$body" +} + +hook_main "$@" diff --git a/src/agent_scan/pushkeys.py b/src/agent_scan/pushkeys.py new file mode 100644 index 0000000..462cbb8 --- /dev/null +++ b/src/agent_scan/pushkeys.py @@ -0,0 +1,86 @@ +"""Push key minting and revocation for EVO.""" + +from __future__ import annotations + +import json +from urllib.error import HTTPError, URLError +from urllib.parse import urlparse +from urllib.request import Request, urlopen + +PLATFORM_API_VERSION = "2025-08-28" + + +def _build_push_key_url(base_url: str, tenant_id: str) -> str: + base = base_url.rstrip("/") + if "/hidden" not in base: + base += "/hidden" + return f"{base}/tenants/{tenant_id}/mcp-scan/push-key?version={PLATFORM_API_VERSION}" + + +def _is_localhost(url: str) -> bool: + host = urlparse(url).hostname or "" + return host in ("localhost", "127.0.0.1", "::1") + + +def mint_push_key( + base_url: str, + tenant_id: str, + snyk_token: str, + description: str | None = None, +) -> str: + """Mint a new push key via the Snyk Platform API. + + Returns the client_id (push key) string. + Raises RuntimeError on failure. + """ + url = _build_push_key_url(base_url, tenant_id) + + body = json.dumps({"description": description}).encode() if description else b"" + + req = Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + if snyk_token and not _is_localhost(base_url): + req.add_header("Authorization", f"token {snyk_token}") + + try: + with urlopen(req, timeout=15) as resp: + data = json.loads(resp.read()) + except HTTPError as e: + body_text = e.read().decode(errors="replace") + raise RuntimeError(f"Push key minting failed: HTTP {e.code} — {body_text}") from e + except (TimeoutError, URLError) as e: + raise RuntimeError(f"Push key minting failed: {e}") from e + + client_id = data.get("client_id") + if not client_id: + raise RuntimeError(f"Unexpected push key response: {data}") + return client_id + + +def revoke_push_key( + base_url: str, + tenant_id: str, + snyk_token: str, + client_id: str, +) -> None: + """Revoke a push key via the Snyk Platform API. + + Raises RuntimeError on failure. + """ + url = _build_push_key_url(base_url, tenant_id) + + req = Request(url, method="DELETE") + req.add_header("Content-Type", "application/json") + req.add_header("x-client-id", client_id) + if snyk_token and not _is_localhost(base_url): + req.add_header("Authorization", f"token {snyk_token}") + + try: + with urlopen(req, timeout=15) as resp: + if resp.status not in (200, 204): + raise RuntimeError(f"Push key revocation failed: HTTP {resp.status}") + except HTTPError as e: + body_text = e.read().decode(errors="replace") + raise RuntimeError(f"Push key revocation failed: HTTP {e.code} — {body_text}") from e + except (TimeoutError, URLError) as e: + raise RuntimeError(f"Push key revocation failed: {e}") from e diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py new file mode 100644 index 0000000..7e45153 --- /dev/null +++ b/tests/unit/test_guard.py @@ -0,0 +1,1332 @@ +"""Tests for agent_scan.guard — install, uninstall, detect for Claude Code and Cursor.""" + +from __future__ import annotations + +import base64 +import json +import shutil +import subprocess +import sys +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +import pytest + +from agent_scan.guard import ( + CLAUDE_EVENTS_WITH_MATCHER, + CLAUDE_HOOK_EVENTS, + CURSOR_HOOK_EVENTS, + _build_hook_command, + _build_hook_command_powershell, + _compact_events, + _detect_claude_install, + _detect_cursor_install, + _extract_env_from_cmd, + _filter_claude_hooks, + _filter_cursor_hooks, + _install_claude, + _install_cursor, + _is_agent_scan_command, + _mask_key, + _parse_command_info, + _shell_quote, + _uninstall_claude, + _uninstall_cursor, +) + +# --------------------------------------------------------------------------- +# Helpers to build hook data +# --------------------------------------------------------------------------- + +AGENT_SCAN_CMD = ( + "PUSH_KEY='pk-1234' REMOTE_HOOKS_BASE_URL='https://api.snyk.io' " + "TENANT_ID='tid-1' bash '/home/u/.claude/hooks/snyk-agent-guard.sh' --client claude-code" +) + +OTHER_CMD = "some-other-tool hook --client claude-code" + +AGENTGUARD_CMD = ( + "PUSH_KEY='pk-old' REMOTE_HOOKS_BASE_URL='https://api.snyk.io' " + "'/usr/local/bin/agentguard' hook --client claude-code" +) + + +def _claude_group(command: str, matcher: str | None = None) -> dict: + g: dict = {"hooks": [{"type": "command", "command": command}]} + if matcher: + g["matcher"] = matcher + return g + + +def _cursor_entry(command: str) -> dict: + return {"command": command} + + +def _write(path: Path, data) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2) + "\n") + + +# =================================================================== +# Unit tests for pure helpers +# =================================================================== + + +class TestIsAgentScanCommand: + def test_matches_snyk_agent_guard_in_path(self): + assert _is_agent_scan_command("bash /home/u/.claude/hooks/snyk-agent-guard.sh") + + def test_matches_agent_scan_marker_in_env(self): + assert _is_agent_scan_command("PUSH_KEY='x' bash snyk-agent-guard.sh --client c") + + def test_no_match_other_tool(self): + assert not _is_agent_scan_command("some-other-tool hook --client claude") + + def test_no_match_agentguard(self): + assert not _is_agent_scan_command("/usr/local/bin/agentguard hook --client claude-code") + + def test_no_match_empty(self): + assert not _is_agent_scan_command("") + + +class TestShellQuote: + def test_simple(self): + assert _shell_quote("hello") == "'hello'" + + def test_with_single_quote(self): + assert _shell_quote("it's") == "'it'\"'\"'s'" + + def test_empty(self): + assert _shell_quote("") == "''" + + +class TestMaskKey: + def test_short_key(self): + assert _mask_key("abcd") == "abcd" + + def test_exactly_8(self): + assert _mask_key("12345678") == "12345678" + + def test_long_key(self): + assert _mask_key("abcdefghijklmnop") == "abcd...mnop" + + +class TestCompactEvents: + def test_empty(self): + assert _compact_events([]) == "(no hooks)" + + def test_one(self): + assert _compact_events(["A"]) == "(A)" + + def test_two(self): + assert _compact_events(["A", "B"]) == "(A, B)" + + def test_three(self): + assert _compact_events(["A", "B", "C"]) == "(A, B + 1 more)" + + def test_nine(self): + assert _compact_events(list("ABCDEFGHI")) == "(A, B + 7 more)" + + +class TestExtractEnvFromCmd: + def test_single_quoted(self): + assert _extract_env_from_cmd("PUSH_KEY='abc-123' bash x", "PUSH_KEY") == "abc-123" + + def test_unquoted(self): + assert _extract_env_from_cmd("PUSH_KEY=abc123 bash x", "PUSH_KEY") == "abc123" + + def test_missing(self): + assert _extract_env_from_cmd("bash x", "PUSH_KEY") == "" + + def test_multiple_keys(self): + cmd = "PUSH_KEY='pk' REMOTE_HOOKS_BASE_URL='https://api.snyk.io' bash x" + assert _extract_env_from_cmd(cmd, "PUSH_KEY") == "pk" + assert _extract_env_from_cmd(cmd, "REMOTE_HOOKS_BASE_URL") == "https://api.snyk.io" + + def test_tenant_id(self): + cmd = "PUSH_KEY='pk' TENANT_ID='tid-1' bash x" + assert _extract_env_from_cmd(cmd, "TENANT_ID") == "tid-1" + + +class TestBuildHookCommand: + @pytest.mark.skipif(sys.platform == "win32", reason="bash command format") + def test_without_tenant_bash(self): + cmd = _build_hook_command("pk", "https://api.snyk.io", Path("/x/hook.sh"), "claude-code") + assert "PUSH_KEY='pk'" in cmd + assert "REMOTE_HOOKS_BASE_URL='https://api.snyk.io'" in cmd + assert "TENANT_ID" not in cmd + assert "bash '/x/hook.sh'" in cmd + assert "--client claude-code" in cmd + + @pytest.mark.skipif(sys.platform == "win32", reason="bash command format") + def test_with_tenant_bash(self): + cmd = _build_hook_command("pk", "https://api.snyk.io", Path("/x/hook.sh"), "cursor", tenant_id="tid") + assert "TENANT_ID='tid'" in cmd + + @pytest.mark.skipif(sys.platform != "win32", reason="powershell command format") + def test_without_tenant_powershell(self): + cmd = _build_hook_command("pk", "https://api.snyk.io", Path("/x/hook.ps1"), "claude-code") + assert "-PushKey 'pk'" in cmd + assert "-RemoteUrl 'https://api.snyk.io'" in cmd + assert "powershell -File" in cmd + assert "-Client claude-code" in cmd + + @pytest.mark.skipif(sys.platform != "win32", reason="powershell command format") + def test_without_tenant_powershell_no_tenant_id(self): + cmd = _build_hook_command("pk", "https://api.snyk.io", Path("/x/hook.ps1"), "claude-code") + assert "TENANT_ID" not in cmd + + def test_roundtrip_extract(self): + cmd = _build_hook_command( + "my-key", "https://example.com", Path("/x/snyk-agent-guard.sh"), "claude-code", tenant_id="t-1" + ) + assert _extract_env_from_cmd(cmd, "PUSH_KEY") == "my-key" + assert _extract_env_from_cmd(cmd, "REMOTE_HOOKS_BASE_URL") == "https://example.com" + # tenant_id is only in bash commands, not powershell + if sys.platform != "win32": + assert _extract_env_from_cmd(cmd, "TENANT_ID") == "t-1" + + +class TestParseCommandInfo: + def test_full_command(self): + info = _parse_command_info(AGENT_SCAN_CMD, ["PreToolUse", "Stop"]) + assert info["host"] == "api.snyk.io" + assert info["auth_value"] == "pk-1234" + assert info["tenant_id"] == "tid-1" + assert info["events"] == ["PreToolUse", "Stop"] + + def test_no_tenant(self): + cmd = "PUSH_KEY='pk' REMOTE_HOOKS_BASE_URL='https://api.snyk.io' bash snyk-agent-guard.sh --client c" + info = _parse_command_info(cmd, ["Stop"]) + assert info["tenant_id"] == "" + + +# =================================================================== +# Claude Code: install +# =================================================================== + + +class TestInstallClaude: + def test_creates_file_when_missing(self, tmp_path): + path = tmp_path / "settings.json" + _install_claude(AGENT_SCAN_CMD, path) + + data = json.loads(path.read_text()) + hooks = data["hooks"] + assert set(hooks.keys()) == set(CLAUDE_HOOK_EVENTS) + for event in CLAUDE_HOOK_EVENTS: + groups = hooks[event] + assert len(groups) == 1 + assert groups[0]["hooks"][0]["command"] == AGENT_SCAN_CMD + if event in CLAUDE_EVENTS_WITH_MATCHER: + assert groups[0]["matcher"] == "*" + else: + assert "matcher" not in groups[0] + + def test_preserves_other_top_level_keys(self, tmp_path): + path = tmp_path / "settings.json" + _write(path, {"allowedTools": ["Bash"], "theme": "dark"}) + _install_claude(AGENT_SCAN_CMD, path) + + data = json.loads(path.read_text()) + assert data["allowedTools"] == ["Bash"] + assert data["theme"] == "dark" + assert "hooks" in data + + def test_preserves_other_hooks(self, tmp_path): + path = tmp_path / "settings.json" + _write( + path, + { + "hooks": { + "PreToolUse": [_claude_group(OTHER_CMD, "*")], + "Stop": [_claude_group(OTHER_CMD)], + } + }, + ) + _install_claude(AGENT_SCAN_CMD, path) + + data = json.loads(path.read_text()) + # PreToolUse should have the other hook + our hook + groups = data["hooks"]["PreToolUse"] + assert len(groups) == 2 + assert groups[0]["hooks"][0]["command"] == OTHER_CMD + assert groups[1]["hooks"][0]["command"] == AGENT_SCAN_CMD + + def test_replaces_old_agent_scan_hooks(self, tmp_path): + path = tmp_path / "settings.json" + old_cmd = AGENT_SCAN_CMD.replace("pk-1234", "pk-old") + _write( + path, + { + "hooks": { + "PreToolUse": [_claude_group(old_cmd, "*")], + "Stop": [_claude_group(old_cmd)], + } + }, + ) + new_cmd = AGENT_SCAN_CMD.replace("pk-1234", "pk-new") + _install_claude(new_cmd, path) + + data = json.loads(path.read_text()) + # Old hooks replaced, not duplicated + for event in CLAUDE_HOOK_EVENTS: + groups = data["hooks"][event] + assert len(groups) == 1 + assert groups[0]["hooks"][0]["command"] == new_cmd + + def test_preserves_agentguard_hooks(self, tmp_path): + """agentguard (Go CLI) hooks should not be touched.""" + path = tmp_path / "settings.json" + _write( + path, + { + "hooks": { + "PreToolUse": [_claude_group(AGENTGUARD_CMD, "*")], + } + }, + ) + _install_claude(AGENT_SCAN_CMD, path) + + data = json.loads(path.read_text()) + groups = data["hooks"]["PreToolUse"] + assert len(groups) == 2 + assert groups[0]["hooks"][0]["command"] == AGENTGUARD_CMD + assert groups[1]["hooks"][0]["command"] == AGENT_SCAN_CMD + + def test_idempotent_no_rewrite(self, tmp_path): + path = tmp_path / "settings.json" + _install_claude(AGENT_SCAN_CMD, path) + mtime1 = path.stat().st_mtime_ns + + _install_claude(AGENT_SCAN_CMD, path) + mtime2 = path.stat().st_mtime_ns + assert mtime1 == mtime2, "File should not have been rewritten" + + def test_backup_created_on_change(self, tmp_path): + path = tmp_path / "settings.json" + _write(path, {"existing": True}) + _install_claude(AGENT_SCAN_CMD, path) + + backup = Path(str(path) + ".backup") + assert backup.exists() + backup_data = json.loads(backup.read_text()) + assert backup_data == {"existing": True} + + def test_no_backup_when_file_missing(self, tmp_path): + path = tmp_path / "settings.json" + _install_claude(AGENT_SCAN_CMD, path) + + backup = Path(str(path) + ".backup") + assert not backup.exists() + + def test_empty_hooks_object(self, tmp_path): + path = tmp_path / "settings.json" + _write(path, {"hooks": {}}) + _install_claude(AGENT_SCAN_CMD, path) + + data = json.loads(path.read_text()) + assert len(data["hooks"]) == len(CLAUDE_HOOK_EVENTS) + + def test_partial_hooks_existing(self, tmp_path): + """File has hooks for only some events.""" + path = tmp_path / "settings.json" + _write( + path, + { + "hooks": { + "PreToolUse": [_claude_group(OTHER_CMD, "*")], + } + }, + ) + _install_claude(AGENT_SCAN_CMD, path) + + data = json.loads(path.read_text()) + assert set(data["hooks"].keys()) == set(CLAUDE_HOOK_EVENTS) + # PreToolUse has both + assert len(data["hooks"]["PreToolUse"]) == 2 + + def test_extra_unknown_events_preserved(self, tmp_path): + """Hooks for events we don't manage should be left alone.""" + path = tmp_path / "settings.json" + _write( + path, + { + "hooks": { + "FutureEvent": [_claude_group(OTHER_CMD)], + } + }, + ) + _install_claude(AGENT_SCAN_CMD, path) + + data = json.loads(path.read_text()) + assert "FutureEvent" in data["hooks"] + assert data["hooks"]["FutureEvent"][0]["hooks"][0]["command"] == OTHER_CMD + + def test_deprecated_agent_scan_events_removed(self, tmp_path): + """Old agent-scan hooks for events no longer in our list get cleaned up.""" + path = tmp_path / "settings.json" + _write( + path, + { + "hooks": { + "DeprecatedEvent": [_claude_group(AGENT_SCAN_CMD)], + "PreToolUse": [_claude_group(AGENT_SCAN_CMD, "*")], + } + }, + ) + _install_claude(AGENT_SCAN_CMD, path) + + data = json.loads(path.read_text()) + assert "DeprecatedEvent" not in data["hooks"] + assert "PreToolUse" in data["hooks"] + + def test_deprecated_event_preserves_other_hooks(self, tmp_path): + """Deprecated event with mixed hooks: agent-scan removed, others kept.""" + path = tmp_path / "settings.json" + _write( + path, + { + "hooks": { + "DeprecatedEvent": [ + _claude_group(OTHER_CMD), + _claude_group(AGENT_SCAN_CMD), + ], + } + }, + ) + _install_claude(AGENT_SCAN_CMD, path) + + data = json.loads(path.read_text()) + assert "DeprecatedEvent" in data["hooks"] + assert len(data["hooks"]["DeprecatedEvent"]) == 1 + assert data["hooks"]["DeprecatedEvent"][0]["hooks"][0]["command"] == OTHER_CMD + + +# =================================================================== +# Claude Code: uninstall +# =================================================================== + + +class TestUninstallClaude: + def test_missing_file(self, tmp_path): + path = tmp_path / "settings.json" + _uninstall_claude(path) # should not raise + + def test_no_hooks_key(self, tmp_path): + path = tmp_path / "settings.json" + _write(path, {"allowedTools": ["Bash"]}) + _uninstall_claude(path) + + data = json.loads(path.read_text()) + assert data == {"allowedTools": ["Bash"]} + + def test_no_agent_scan_hooks(self, tmp_path): + path = tmp_path / "settings.json" + _write(path, {"hooks": {"PreToolUse": [_claude_group(OTHER_CMD, "*")]}}) + _uninstall_claude(path) + + data = json.loads(path.read_text()) + assert len(data["hooks"]["PreToolUse"]) == 1 + + def test_removes_only_agent_scan(self, tmp_path): + path = tmp_path / "settings.json" + _write( + path, + { + "hooks": { + "PreToolUse": [ + _claude_group(OTHER_CMD, "*"), + _claude_group(AGENT_SCAN_CMD, "*"), + ], + "Stop": [_claude_group(AGENT_SCAN_CMD)], + } + }, + ) + _uninstall_claude(path) + + data = json.loads(path.read_text()) + # PreToolUse keeps the other hook + assert len(data["hooks"]["PreToolUse"]) == 1 + assert data["hooks"]["PreToolUse"][0]["hooks"][0]["command"] == OTHER_CMD + # Stop was only agent-scan, so the event key is removed + assert "Stop" not in data["hooks"] + + def test_removes_hooks_key_when_empty(self, tmp_path): + path = tmp_path / "settings.json" + _write( + path, + { + "hooks": { + "PreToolUse": [_claude_group(AGENT_SCAN_CMD, "*")], + } + }, + ) + _uninstall_claude(path) + + data = json.loads(path.read_text()) + assert "hooks" not in data + + def test_preserves_agentguard(self, tmp_path): + path = tmp_path / "settings.json" + _write( + path, + { + "hooks": { + "PreToolUse": [ + _claude_group(AGENTGUARD_CMD, "*"), + _claude_group(AGENT_SCAN_CMD, "*"), + ], + } + }, + ) + _uninstall_claude(path) + + data = json.loads(path.read_text()) + assert len(data["hooks"]["PreToolUse"]) == 1 + assert data["hooks"]["PreToolUse"][0]["hooks"][0]["command"] == AGENTGUARD_CMD + + def test_backup_created(self, tmp_path): + path = tmp_path / "settings.json" + original = {"hooks": {"Stop": [_claude_group(AGENT_SCAN_CMD)]}} + _write(path, original) + _uninstall_claude(path) + + backup = Path(str(path) + ".backup") + assert backup.exists() + assert json.loads(backup.read_text()) == original + + def test_full_install_then_uninstall(self, tmp_path): + """Install all events, then uninstall — should leave a clean file.""" + path = tmp_path / "settings.json" + _write(path, {"allowedTools": ["Bash"]}) + _install_claude(AGENT_SCAN_CMD, path) + _uninstall_claude(path) + + data = json.loads(path.read_text()) + assert "hooks" not in data + assert data["allowedTools"] == ["Bash"] + + +# =================================================================== +# Claude Code: detect +# =================================================================== + + +class TestDetectClaude: + def test_missing_file(self, tmp_path): + assert _detect_claude_install(tmp_path / "nope.json") is None + + def test_empty_file(self, tmp_path): + path = tmp_path / "settings.json" + _write(path, {}) + assert _detect_claude_install(path) is None + + def test_no_agent_scan_hooks(self, tmp_path): + path = tmp_path / "settings.json" + _write(path, {"hooks": {"PreToolUse": [_claude_group(OTHER_CMD, "*")]}}) + assert _detect_claude_install(path) is None + + def test_detects_installed(self, tmp_path): + path = tmp_path / "settings.json" + _install_claude(AGENT_SCAN_CMD, path) + + info = _detect_claude_install(path) + assert info is not None + assert info["host"] == "api.snyk.io" + assert info["auth_value"] == "pk-1234" + assert info["tenant_id"] == "tid-1" + assert len(info["events"]) == len(CLAUDE_HOOK_EVENTS) + + def test_detects_partial_install(self, tmp_path): + """Only some events have our hooks.""" + path = tmp_path / "settings.json" + _write( + path, + { + "hooks": { + "PreToolUse": [_claude_group(AGENT_SCAN_CMD, "*")], + "Stop": [_claude_group(AGENT_SCAN_CMD)], + } + }, + ) + info = _detect_claude_install(path) + assert info is not None + assert info["events"] == ["PreToolUse", "Stop"] + + def test_ignores_agentguard(self, tmp_path): + path = tmp_path / "settings.json" + _write(path, {"hooks": {"PreToolUse": [_claude_group(AGENTGUARD_CMD, "*")]}}) + assert _detect_claude_install(path) is None + + def test_detects_among_other_hooks(self, tmp_path): + path = tmp_path / "settings.json" + _write( + path, + { + "hooks": { + "PreToolUse": [ + _claude_group(AGENTGUARD_CMD, "*"), + _claude_group(AGENT_SCAN_CMD, "*"), + ], + } + }, + ) + info = _detect_claude_install(path) + assert info is not None + assert info["events"] == ["PreToolUse"] + + def test_invalid_json(self, tmp_path): + path = tmp_path / "settings.json" + path.write_text("not json at all") + with pytest.raises(json.JSONDecodeError): + _detect_claude_install(path) + + +# =================================================================== +# Cursor: install +# =================================================================== + +CURSOR_AGENT_SCAN_CMD = ( + "PUSH_KEY='pk-1234' REMOTE_HOOKS_BASE_URL='https://api.snyk.io' " + "TENANT_ID='tid-1' bash '/home/u/.cursor/hooks/snyk-agent-guard.sh' --client cursor" +) + +CURSOR_OTHER_CMD = "some-other-cursor-hook --flag" + +CURSOR_AGENTGUARD_CMD = ( + "PUSH_KEY='pk-old' REMOTE_HOOKS_BASE_URL='https://api.snyk.io' '/usr/local/bin/agentguard' hook --client cursor" +) + + +class TestInstallCursor: + def test_creates_file_when_missing(self, tmp_path): + path = tmp_path / "hooks.json" + _install_cursor(CURSOR_AGENT_SCAN_CMD, path) + + data = json.loads(path.read_text()) + assert data["version"] == 1 + hooks = data["hooks"] + assert set(hooks.keys()) == set(CURSOR_HOOK_EVENTS) + for event in CURSOR_HOOK_EVENTS: + entries = hooks[event] + assert len(entries) == 1 + assert entries[0]["command"] == CURSOR_AGENT_SCAN_CMD + + def test_preserves_version(self, tmp_path): + path = tmp_path / "hooks.json" + _write(path, {"version": 2, "hooks": {}}) + _install_cursor(CURSOR_AGENT_SCAN_CMD, path) + + data = json.loads(path.read_text()) + assert data["version"] == 2 + + def test_adds_version_if_missing(self, tmp_path): + path = tmp_path / "hooks.json" + _write(path, {"hooks": {}}) + _install_cursor(CURSOR_AGENT_SCAN_CMD, path) + + data = json.loads(path.read_text()) + assert data["version"] == 1 + + def test_preserves_other_hooks(self, tmp_path): + path = tmp_path / "hooks.json" + _write( + path, + { + "version": 1, + "hooks": { + "beforeSubmitPrompt": [_cursor_entry(CURSOR_OTHER_CMD)], + }, + }, + ) + _install_cursor(CURSOR_AGENT_SCAN_CMD, path) + + data = json.loads(path.read_text()) + entries = data["hooks"]["beforeSubmitPrompt"] + assert len(entries) == 2 + assert entries[0]["command"] == CURSOR_OTHER_CMD + assert entries[1]["command"] == CURSOR_AGENT_SCAN_CMD + + def test_replaces_old_agent_scan_hooks(self, tmp_path): + path = tmp_path / "hooks.json" + old_cmd = CURSOR_AGENT_SCAN_CMD.replace("pk-1234", "pk-old") + _write( + path, + { + "version": 1, + "hooks": { + "beforeSubmitPrompt": [_cursor_entry(old_cmd)], + "stop": [_cursor_entry(old_cmd)], + }, + }, + ) + new_cmd = CURSOR_AGENT_SCAN_CMD.replace("pk-1234", "pk-new") + _install_cursor(new_cmd, path) + + data = json.loads(path.read_text()) + for event in CURSOR_HOOK_EVENTS: + entries = data["hooks"][event] + assert len(entries) == 1 + assert entries[0]["command"] == new_cmd + + def test_preserves_agentguard_hooks(self, tmp_path): + path = tmp_path / "hooks.json" + _write( + path, + { + "version": 1, + "hooks": { + "beforeSubmitPrompt": [_cursor_entry(CURSOR_AGENTGUARD_CMD)], + }, + }, + ) + _install_cursor(CURSOR_AGENT_SCAN_CMD, path) + + data = json.loads(path.read_text()) + entries = data["hooks"]["beforeSubmitPrompt"] + assert len(entries) == 2 + assert entries[0]["command"] == CURSOR_AGENTGUARD_CMD + + def test_idempotent_no_rewrite(self, tmp_path): + path = tmp_path / "hooks.json" + _install_cursor(CURSOR_AGENT_SCAN_CMD, path) + mtime1 = path.stat().st_mtime_ns + + _install_cursor(CURSOR_AGENT_SCAN_CMD, path) + mtime2 = path.stat().st_mtime_ns + assert mtime1 == mtime2 + + def test_backup_created_on_change(self, tmp_path): + path = tmp_path / "hooks.json" + _write(path, {"version": 1, "hooks": {}}) + _install_cursor(CURSOR_AGENT_SCAN_CMD, path) + + backup = Path(str(path) + ".backup") + assert backup.exists() + + def test_empty_hooks_object(self, tmp_path): + path = tmp_path / "hooks.json" + _write(path, {"version": 1, "hooks": {}}) + _install_cursor(CURSOR_AGENT_SCAN_CMD, path) + + data = json.loads(path.read_text()) + assert len(data["hooks"]) == len(CURSOR_HOOK_EVENTS) + + def test_partial_hooks_existing(self, tmp_path): + path = tmp_path / "hooks.json" + _write( + path, + { + "version": 1, + "hooks": { + "stop": [_cursor_entry(CURSOR_OTHER_CMD)], + }, + }, + ) + _install_cursor(CURSOR_AGENT_SCAN_CMD, path) + + data = json.loads(path.read_text()) + assert set(data["hooks"].keys()) == set(CURSOR_HOOK_EVENTS) + assert len(data["hooks"]["stop"]) == 2 + + def test_extra_unknown_events_preserved(self, tmp_path): + path = tmp_path / "hooks.json" + _write( + path, + { + "version": 1, + "hooks": { + "futureEvent": [_cursor_entry(CURSOR_OTHER_CMD)], + }, + }, + ) + _install_cursor(CURSOR_AGENT_SCAN_CMD, path) + + data = json.loads(path.read_text()) + assert "futureEvent" in data["hooks"] + + def test_more_events_than_supported(self, tmp_path): + """File has hooks for events beyond what we manage — they should survive.""" + path = tmp_path / "hooks.json" + hooks = {event: [_cursor_entry(CURSOR_OTHER_CMD)] for event in CURSOR_HOOK_EVENTS} + hooks["extraEvent1"] = [_cursor_entry(CURSOR_OTHER_CMD)] + hooks["extraEvent2"] = [_cursor_entry(CURSOR_OTHER_CMD)] + _write(path, {"version": 1, "hooks": hooks}) + _install_cursor(CURSOR_AGENT_SCAN_CMD, path) + + data = json.loads(path.read_text()) + assert "extraEvent1" in data["hooks"] + assert "extraEvent2" in data["hooks"] + # Each managed event has 2 entries (other + ours) + for event in CURSOR_HOOK_EVENTS: + assert len(data["hooks"][event]) == 2 + + def test_deprecated_agent_scan_events_removed(self, tmp_path): + """Old agent-scan hooks for events no longer in our list get cleaned up.""" + path = tmp_path / "hooks.json" + _write( + path, + { + "version": 1, + "hooks": { + "deprecatedEvent": [_cursor_entry(CURSOR_AGENT_SCAN_CMD)], + "stop": [_cursor_entry(CURSOR_AGENT_SCAN_CMD)], + }, + }, + ) + _install_cursor(CURSOR_AGENT_SCAN_CMD, path) + + data = json.loads(path.read_text()) + assert "deprecatedEvent" not in data["hooks"] + assert "stop" in data["hooks"] + + def test_deprecated_event_preserves_other_hooks(self, tmp_path): + """Deprecated event with mixed hooks: agent-scan removed, others kept.""" + path = tmp_path / "hooks.json" + _write( + path, + { + "version": 1, + "hooks": { + "deprecatedEvent": [ + _cursor_entry(CURSOR_OTHER_CMD), + _cursor_entry(CURSOR_AGENT_SCAN_CMD), + ], + }, + }, + ) + _install_cursor(CURSOR_AGENT_SCAN_CMD, path) + + data = json.loads(path.read_text()) + assert "deprecatedEvent" in data["hooks"] + assert len(data["hooks"]["deprecatedEvent"]) == 1 + assert data["hooks"]["deprecatedEvent"][0]["command"] == CURSOR_OTHER_CMD + + +# =================================================================== +# Cursor: uninstall +# =================================================================== + + +class TestUninstallCursor: + def test_missing_file(self, tmp_path): + path = tmp_path / "hooks.json" + _uninstall_cursor(path) # should not raise + + def test_no_hooks_key(self, tmp_path): + path = tmp_path / "hooks.json" + _write(path, {"version": 1}) + _uninstall_cursor(path) + + data = json.loads(path.read_text()) + assert data == {"version": 1} + + def test_no_agent_scan_hooks(self, tmp_path): + path = tmp_path / "hooks.json" + _write(path, {"version": 1, "hooks": {"stop": [_cursor_entry(CURSOR_OTHER_CMD)]}}) + _uninstall_cursor(path) + + data = json.loads(path.read_text()) + assert len(data["hooks"]["stop"]) == 1 + + def test_removes_only_agent_scan(self, tmp_path): + path = tmp_path / "hooks.json" + _write( + path, + { + "version": 1, + "hooks": { + "stop": [ + _cursor_entry(CURSOR_OTHER_CMD), + _cursor_entry(CURSOR_AGENT_SCAN_CMD), + ], + "sessionStart": [_cursor_entry(CURSOR_AGENT_SCAN_CMD)], + }, + }, + ) + _uninstall_cursor(path) + + data = json.loads(path.read_text()) + assert len(data["hooks"]["stop"]) == 1 + assert data["hooks"]["stop"][0]["command"] == CURSOR_OTHER_CMD + assert "sessionStart" not in data["hooks"] + + def test_leaves_empty_hooks_when_all_removed(self, tmp_path): + path = tmp_path / "hooks.json" + _write(path, {"version": 1, "hooks": {"stop": [_cursor_entry(CURSOR_AGENT_SCAN_CMD)]}}) + _uninstall_cursor(path) + + data = json.loads(path.read_text()) + assert data["hooks"] == {} + + def test_preserves_agentguard(self, tmp_path): + path = tmp_path / "hooks.json" + _write( + path, + { + "version": 1, + "hooks": { + "stop": [ + _cursor_entry(CURSOR_AGENTGUARD_CMD), + _cursor_entry(CURSOR_AGENT_SCAN_CMD), + ], + }, + }, + ) + _uninstall_cursor(path) + + data = json.loads(path.read_text()) + assert len(data["hooks"]["stop"]) == 1 + assert data["hooks"]["stop"][0]["command"] == CURSOR_AGENTGUARD_CMD + + def test_backup_created(self, tmp_path): + path = tmp_path / "hooks.json" + original = {"version": 1, "hooks": {"stop": [_cursor_entry(CURSOR_AGENT_SCAN_CMD)]}} + _write(path, original) + _uninstall_cursor(path) + + backup = Path(str(path) + ".backup") + assert backup.exists() + assert json.loads(backup.read_text()) == original + + def test_full_install_then_uninstall(self, tmp_path): + path = tmp_path / "hooks.json" + _install_cursor(CURSOR_AGENT_SCAN_CMD, path) + _uninstall_cursor(path) + + data = json.loads(path.read_text()) + assert data["hooks"] == {} + assert data["version"] == 1 + + +# =================================================================== +# Cursor: detect +# =================================================================== + + +class TestDetectCursor: + def test_missing_file(self, tmp_path): + assert _detect_cursor_install(tmp_path / "nope.json") is None + + def test_empty_file(self, tmp_path): + path = tmp_path / "hooks.json" + _write(path, {"version": 1}) + assert _detect_cursor_install(path) is None + + def test_no_agent_scan_hooks(self, tmp_path): + path = tmp_path / "hooks.json" + _write(path, {"version": 1, "hooks": {"stop": [_cursor_entry(CURSOR_OTHER_CMD)]}}) + assert _detect_cursor_install(path) is None + + def test_detects_installed(self, tmp_path): + path = tmp_path / "hooks.json" + _install_cursor(CURSOR_AGENT_SCAN_CMD, path) + + info = _detect_cursor_install(path) + assert info is not None + assert info["host"] == "api.snyk.io" + assert info["auth_value"] == "pk-1234" + assert info["tenant_id"] == "tid-1" + assert len(info["events"]) == len(CURSOR_HOOK_EVENTS) + + def test_detects_partial_install(self, tmp_path): + path = tmp_path / "hooks.json" + _write( + path, + { + "version": 1, + "hooks": { + "stop": [_cursor_entry(CURSOR_AGENT_SCAN_CMD)], + "sessionEnd": [_cursor_entry(CURSOR_AGENT_SCAN_CMD)], + }, + }, + ) + info = _detect_cursor_install(path) + assert info is not None + assert info["events"] == ["stop", "sessionEnd"] + + def test_ignores_agentguard(self, tmp_path): + path = tmp_path / "hooks.json" + _write(path, {"version": 1, "hooks": {"stop": [_cursor_entry(CURSOR_AGENTGUARD_CMD)]}}) + assert _detect_cursor_install(path) is None + + def test_detects_among_other_hooks(self, tmp_path): + path = tmp_path / "hooks.json" + _write( + path, + { + "version": 1, + "hooks": { + "stop": [ + _cursor_entry(CURSOR_AGENTGUARD_CMD), + _cursor_entry(CURSOR_AGENT_SCAN_CMD), + ], + }, + }, + ) + info = _detect_cursor_install(path) + assert info is not None + assert info["events"] == ["stop"] + + def test_invalid_json(self, tmp_path): + path = tmp_path / "hooks.json" + path.write_text("{broken json") + with pytest.raises(json.JSONDecodeError): + _detect_cursor_install(path) + + +# =================================================================== +# Filter functions +# =================================================================== + + +class TestFilterClaudeHooks: + def test_empty(self): + assert _filter_claude_hooks({}) == {} + + def test_removes_agent_scan(self): + hooks = {"PreToolUse": [_claude_group(AGENT_SCAN_CMD, "*")]} + assert _filter_claude_hooks(hooks) == {} + + def test_keeps_other(self): + hooks = {"PreToolUse": [_claude_group(OTHER_CMD, "*")]} + result = _filter_claude_hooks(hooks) + assert len(result["PreToolUse"]) == 1 + + def test_mixed(self): + hooks = { + "PreToolUse": [ + _claude_group(OTHER_CMD, "*"), + _claude_group(AGENT_SCAN_CMD, "*"), + ] + } + result = _filter_claude_hooks(hooks) + assert len(result["PreToolUse"]) == 1 + assert result["PreToolUse"][0]["hooks"][0]["command"] == OTHER_CMD + + +class TestFilterCursorHooks: + def test_empty(self): + assert _filter_cursor_hooks({}) == {} + + def test_removes_agent_scan(self): + hooks = {"stop": [_cursor_entry(CURSOR_AGENT_SCAN_CMD)]} + assert _filter_cursor_hooks(hooks) == {} + + def test_keeps_other(self): + hooks = {"stop": [_cursor_entry(CURSOR_OTHER_CMD)]} + result = _filter_cursor_hooks(hooks) + assert len(result["stop"]) == 1 + + def test_mixed(self): + hooks = { + "stop": [ + _cursor_entry(CURSOR_OTHER_CMD), + _cursor_entry(CURSOR_AGENT_SCAN_CMD), + ] + } + result = _filter_cursor_hooks(hooks) + assert len(result["stop"]) == 1 + assert result["stop"][0]["command"] == CURSOR_OTHER_CMD + + +# =================================================================== +# Hook script integration tests +# =================================================================== + + +class _HookHandler(BaseHTTPRequestHandler): + """Captures the last POST request for assertions.""" + + last_request: dict | None = None + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length).decode() + _HookHandler.last_request = { + "path": self.path, + "body": body, + "headers": dict(self.headers), + } + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"ok":true}') + + def log_message(self, format, *args): + pass # silence logs + + +@pytest.fixture() +def hook_server(): + """Start a throwaway HTTP server and yield its base URL.""" + server = HTTPServer(("127.0.0.1", 0), _HookHandler) + port = server.server_address[1] + t = threading.Thread(target=server.serve_forever, daemon=True) + t.start() + _HookHandler.last_request = None + yield f"http://127.0.0.1:{port}" + server.shutdown() + + +def _get_script_path(name: str) -> Path: + from importlib import resources as importlib_resources + + return Path(str(importlib_resources.files("agent_scan.hooks").joinpath(name))) + + +IS_WINDOWS = sys.platform == "win32" + + +@pytest.mark.skipif(IS_WINDOWS, reason="bash script; skipped on Windows") +class TestBashHookScript: + """Integration: invoke the real .sh script against a local HTTP server.""" + + @pytest.fixture(autouse=True) + def _skip_no_bash(self): + if not shutil.which("bash"): + pytest.skip("bash not available") + + def test_posts_base64_payload(self, hook_server): + script = _get_script_path("snyk-agent-guard.sh") + payload = '{"hook_event_name":"test","session_id":"s1"}' + result = subprocess.run( + ["bash", str(script), "--client", "claude-code"], + input=payload, + capture_output=True, + text=True, + timeout=10, + env={ + "PATH": "/usr/bin:/bin:/usr/local/bin", + "PUSH_KEY": "test-pk-123", + "REMOTE_HOOKS_BASE_URL": hook_server, + }, + ) + assert result.returncode == 0, result.stderr + + req = _HookHandler.last_request + assert req is not None + assert "/hidden/agent-monitor/hooks/claude-code" in req["path"] + assert req["headers"]["X-Client-Id"] == "test-pk-123" + assert req["body"].startswith("base64:") + decoded = base64.b64decode(req["body"].removeprefix("base64:")) + assert json.loads(decoded) == json.loads(payload) + + def test_cursor_endpoint(self, hook_server): + script = _get_script_path("snyk-agent-guard.sh") + payload = '{"hook_event_name":"test","conversation_id":"c1"}' + result = subprocess.run( + ["bash", str(script), "--client", "cursor"], + input=payload, + capture_output=True, + text=True, + timeout=10, + env={ + "PATH": "/usr/bin:/bin:/usr/local/bin", + "PUSH_KEY": "test-pk-456", + "REMOTE_HOOKS_BASE_URL": hook_server, + }, + ) + assert result.returncode == 0, result.stderr + assert "/hidden/agent-monitor/hooks/cursor" in _HookHandler.last_request["path"] + + def test_missing_push_key_fails(self, hook_server): + script = _get_script_path("snyk-agent-guard.sh") + result = subprocess.run( + ["bash", str(script), "--client", "claude-code"], + input="{}", + capture_output=True, + text=True, + timeout=10, + env={ + "PATH": "/usr/bin:/bin:/usr/local/bin", + "REMOTE_HOOKS_BASE_URL": hook_server, + }, + ) + assert result.returncode != 0 + assert "PUSH_KEY" in result.stderr + + def test_missing_url_fails(self): + script = _get_script_path("snyk-agent-guard.sh") + result = subprocess.run( + ["bash", str(script), "--client", "claude-code"], + input="{}", + capture_output=True, + text=True, + timeout=10, + env={ + "PATH": "/usr/bin:/bin:/usr/local/bin", + "PUSH_KEY": "pk", + }, + ) + assert result.returncode != 0 + assert "REMOTE_HOOKS_BASE_URL" in result.stderr + + +@pytest.mark.skipif(not IS_WINDOWS, reason="PowerShell script; Windows only") +class TestPowerShellHookScript: + """Integration: invoke the real .ps1 script against a local HTTP server.""" + + @pytest.fixture(autouse=True) + def _skip_no_powershell(self): + if not shutil.which("powershell") and not shutil.which("pwsh"): + pytest.skip("powershell not available") + + @staticmethod + def _ps_cmd(): + return "powershell" if shutil.which("powershell") else "pwsh" + + def test_posts_base64_payload(self, hook_server): + script = _get_script_path("snyk-agent-guard.ps1") + payload = '{"hook_event_name":"test","session_id":"s1"}' + result = subprocess.run( + [ + self._ps_cmd(), + "-File", + str(script), + "-Client", + "claude-code", + "-PushKey", + "test-pk-123", + "-RemoteUrl", + hook_server, + ], + input=payload, + capture_output=True, + text=True, + timeout=15, + ) + assert result.returncode == 0, result.stderr + + req = _HookHandler.last_request + assert req is not None + assert "/hidden/agent-monitor/hooks/claude-code" in req["path"] + assert req["headers"]["X-Client-Id"] == "test-pk-123" + assert req["body"].startswith("base64:") + decoded = base64.b64decode(req["body"].removeprefix("base64:")) + assert json.loads(decoded) == json.loads(payload) + + def test_cursor_endpoint(self, hook_server): + script = _get_script_path("snyk-agent-guard.ps1") + payload = '{"hook_event_name":"test","conversation_id":"c1"}' + result = subprocess.run( + [ + self._ps_cmd(), + "-File", + str(script), + "-Client", + "cursor", + "-PushKey", + "test-pk-456", + "-RemoteUrl", + hook_server, + ], + input=payload, + capture_output=True, + text=True, + timeout=15, + ) + assert result.returncode == 0, result.stderr + assert "/hidden/agent-monitor/hooks/cursor" in _HookHandler.last_request["path"] + + def test_missing_push_key_fails(self, hook_server): + script = _get_script_path("snyk-agent-guard.ps1") + env = dict(__import__("os").environ) + env.pop("PUSH_KEY", None) + env.pop("PUSHKEY", None) + result = subprocess.run( + [self._ps_cmd(), "-File", str(script), "-Client", "claude-code", "-RemoteUrl", hook_server], + input="{}", + capture_output=True, + text=True, + timeout=15, + env=env, + ) + assert result.returncode != 0 + + +# =================================================================== +# End-to-end: command string invoked the way the client shell does it +# =================================================================== + + +@pytest.mark.skipif(not IS_WINDOWS, reason="PowerShell Cursor invocation; Windows only") +class TestCursorStylePowerShellInvocation: + """Verify the built command string works when Cursor passes it to + ``powershell -Command "..."``. + + An earlier version that used ``$env:KEY='...'; ...`` broke because + PowerShell rejected chained expressions in that context. + """ + + @pytest.fixture(autouse=True) + def _skip_no_powershell(self): + if not shutil.which("powershell") and not shutil.which("pwsh"): + pytest.skip("powershell not available") + + @staticmethod + def _ps_cmd(): + return "powershell" if shutil.which("powershell") else "pwsh" + + def test_cursor_invokes_command_string(self, hook_server): + script = _get_script_path("snyk-agent-guard.ps1") + command = _build_hook_command_powershell( + "test-pk-cursor", + hook_server, + script, + "claude-code", + ) + payload = '{"hook_event_name":"test","session_id":"cursor-test"}' + result = subprocess.run( + [self._ps_cmd(), "-Command", command], + input=payload, + capture_output=True, + text=True, + timeout=15, + ) + assert result.returncode == 0, f"Command failed:\n{command}\nstderr: {result.stderr}" + + req = _HookHandler.last_request + assert req is not None + assert "/hidden/agent-monitor/hooks/claude-code" in req["path"] + assert req["headers"]["X-Client-Id"] == "test-pk-cursor" + decoded = base64.b64decode(req["body"].removeprefix("base64:")) + assert json.loads(decoded) == json.loads(payload) + + +@pytest.mark.skipif(IS_WINDOWS, reason="bash invocation; non-Windows only") +class TestCursorStyleBashInvocation: + """Verify the built command string works when passed to ``bash -c``.""" + + @pytest.fixture(autouse=True) + def _skip_no_bash(self): + if not shutil.which("bash"): + pytest.skip("bash not available") + + def test_cursor_invokes_command_string(self, hook_server): + script = _get_script_path("snyk-agent-guard.sh") + command = _build_hook_command( + "test-pk-cursor", + hook_server, + script, + "cursor", + ) + payload = '{"hook_event_name":"test","conversation_id":"cursor-test"}' + result = subprocess.run( + ["bash", "-c", command], + input=payload, + capture_output=True, + text=True, + timeout=10, + env={"PATH": "/usr/bin:/bin:/usr/local/bin"}, + ) + assert result.returncode == 0, f"Command failed:\n{command}\nstderr: {result.stderr}" + + req = _HookHandler.last_request + assert req is not None + assert "/hidden/agent-monitor/hooks/cursor" in req["path"] + assert req["headers"]["X-Client-Id"] == "test-pk-cursor" + decoded = base64.b64decode(req["body"].removeprefix("base64:")) + assert json.loads(decoded) == json.loads(payload) diff --git a/uv.lock b/uv.lock index 39a00f5..faac6e6 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,10 @@ version = 1 revision = 3 requires-python = ">=3.10" +[options] +exclude-newer = "2026-03-31T09:06:34.607561Z" +exclude-newer-span = "P7D" + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -13,7 +17,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.5" +version = "3.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -25,110 +29,110 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/85/cebc47ee74d8b408749073a1a46c6fcba13d170dc8af7e61996c6c9394ac/aiohttp-3.13.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:02222e7e233295f40e011c1b00e3b0bd451f22cf853a0304c3595633ee47da4b", size = 750547, upload-time = "2026-03-31T21:56:30.024Z" }, - { url = "https://files.pythonhosted.org/packages/05/98/afd308e35b9d3d8c9ec54c0918f1d722c86dc17ddfec272fcdbcce5a3124/aiohttp-3.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bace460460ed20614fa6bc8cb09966c0b8517b8c58ad8046828c6078d25333b5", size = 503535, upload-time = "2026-03-31T21:56:31.935Z" }, - { url = "https://files.pythonhosted.org/packages/6f/4d/926c183e06b09d5270a309eb50fbde7b09782bfd305dec1e800f329834fb/aiohttp-3.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f546a4dc1e6a5edbb9fd1fd6ad18134550e096a5a43f4ad74acfbd834fc6670", size = 497830, upload-time = "2026-03-31T21:56:33.654Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d6/f47d1c690f115a5c2a5e8938cce4a232a5be9aac5c5fb2647efcbbbda333/aiohttp-3.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c86969d012e51b8e415a8c6ce96f7857d6a87d6207303ab02d5d11ef0cad2274", size = 1682474, upload-time = "2026-03-31T21:56:35.513Z" }, - { url = "https://files.pythonhosted.org/packages/01/44/056fd37b1bb52eac760303e5196acc74d9d546631b035704ae5927f7b4ac/aiohttp-3.13.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b6f6cd1560c5fa427e3b6074bb24d2c64e225afbb7165008903bd42e4e33e28a", size = 1655259, upload-time = "2026-03-31T21:56:37.843Z" }, - { url = "https://files.pythonhosted.org/packages/91/9f/78eb1a20c1c28ae02f6a3c0f4d7b0dcc66abce5290cadd53d78ce3084175/aiohttp-3.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:636bc362f0c5bbc7372bc3ae49737f9e3030dbce469f0f422c8f38079780363d", size = 1736204, upload-time = "2026-03-31T21:56:39.822Z" }, - { url = "https://files.pythonhosted.org/packages/de/6c/d20d7de23f0b52b8c1d9e2033b2db1ac4dacbb470bb74c56de0f5f86bb4f/aiohttp-3.13.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a7cbeb06d1070f1d14895eeeed4dac5913b22d7b456f2eb969f11f4b3993796", size = 1826198, upload-time = "2026-03-31T21:56:41.378Z" }, - { url = "https://files.pythonhosted.org/packages/2f/86/a6f3ff1fd795f49545a7c74b2c92f62729135d73e7e4055bf74da5a26c82/aiohttp-3.13.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca9ef7517fd7874a1a08970ae88f497bf5c984610caa0bf40bd7e8450852b95", size = 1681329, upload-time = "2026-03-31T21:56:43.374Z" }, - { url = "https://files.pythonhosted.org/packages/fb/68/84cd3dab6b7b4f3e6fe9459a961acb142aaab846417f6e8905110d7027e5/aiohttp-3.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:019a67772e034a0e6b9b17c13d0a8fe56ad9fb150fc724b7f3ffd3724288d9e5", size = 1560023, upload-time = "2026-03-31T21:56:45.031Z" }, - { url = "https://files.pythonhosted.org/packages/41/2c/db61b64b0249e30f954a65ab4cb4970ced57544b1de2e3c98ee5dc24165f/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f34ecee82858e41dd217734f0c41a532bd066bcaab636ad830f03a30b2a96f2a", size = 1652372, upload-time = "2026-03-31T21:56:47.075Z" }, - { url = "https://files.pythonhosted.org/packages/25/6f/e96988a6c982d047810c772e28c43c64c300c943b0ed5c1c0c4ce1e1027c/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4eac02d9af4813ee289cd63a361576da36dba57f5a1ab36377bc2600db0cbb73", size = 1662031, upload-time = "2026-03-31T21:56:48.835Z" }, - { url = "https://files.pythonhosted.org/packages/b7/26/a56feace81f3d347b4052403a9d03754a0ab23f7940780dada0849a38c92/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4beac52e9fe46d6abf98b0176a88154b742e878fdf209d2248e99fcdf73cd297", size = 1708118, upload-time = "2026-03-31T21:56:50.833Z" }, - { url = "https://files.pythonhosted.org/packages/78/6e/b6173a8ff03d01d5e1a694bc06764b5dad1df2d4ed8f0ceec12bb3277936/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c180f480207a9b2475f2b8d8bd7204e47aec952d084b2a2be58a782ffcf96074", size = 1548667, upload-time = "2026-03-31T21:56:52.81Z" }, - { url = "https://files.pythonhosted.org/packages/16/13/13296ffe2c132d888b3fe2c195c8b9c0c24c89c3fa5cc2c44464dc23b22e/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2837fb92951564d6339cedae4a7231692aa9f73cbc4fb2e04263b96844e03b4e", size = 1724490, upload-time = "2026-03-31T21:56:54.541Z" }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1f1c287f4a79782ef36e5a6e62954c85343bc30470d862d30bd5f26c9fa2/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d9010032a0b9710f58012a1e9c222528763d860ba2ee1422c03473eab47703e7", size = 1667109, upload-time = "2026-03-31T21:56:56.21Z" }, - { url = "https://files.pythonhosted.org/packages/ef/42/8461a2aaf60a8f4ea4549a4056be36b904b0eb03d97ca9a8a2604681a500/aiohttp-3.13.5-cp310-cp310-win32.whl", hash = "sha256:7c4b6668b2b2b9027f209ddf647f2a4407784b5d88b8be4efcc72036f365baf9", size = 439478, upload-time = "2026-03-31T21:56:58.292Z" }, - { url = "https://files.pythonhosted.org/packages/e5/71/06956304cb5ee439dfe8d86e1b2e70088bd88ed1ced1f42fb29e5d855f0e/aiohttp-3.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:cd3db5927bf9167d5a6157ddb2f036f6b6b0ad001ac82355d43e97a4bde76d76", size = 462047, upload-time = "2026-03-31T21:57:00.257Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" }, - { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" }, - { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" }, - { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" }, - { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" }, - { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" }, - { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" }, - { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" }, - { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" }, - { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" }, - { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" }, - { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" }, - { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, - { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, - { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, - { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, - { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, - { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, - { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, - { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, - { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, - { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, - { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, - { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, - { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, - { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, - { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, - { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, - { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, - { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, - { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, - { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, - { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, - { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, - { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, - { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, - { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, - { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, - { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, - { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, - { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, - { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, - { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, - { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, - { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, - { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, - { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, - { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, - { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, - { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, - { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, - { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, - { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, - { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, - { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, - { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, - { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, - { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, - { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/05/6817e0390eb47b0867cf8efdb535298191662192281bc3ca62a0cb7973eb/aiohttp-3.13.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6290fe12fe8cefa6ea3c1c5b969d32c010dfe191d4392ff9b599a3f473cbe722", size = 753094, upload-time = "2026-03-28T17:14:59.928Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/e5b7f25f6dd1ab57da92aa9d226b2c8b56f223dd20475d3ddfddaba86ab8/aiohttp-3.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7520d92c0e8fbbe63f36f20a5762db349ff574ad38ad7bc7732558a650439845", size = 505213, upload-time = "2026-03-28T17:15:01.989Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e5/8f42033c7ce98b54dfd3791f03e60231cfe4a2db4471b5fc188df2b8a6ad/aiohttp-3.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2710ae1e1b81d0f187883b6e9d66cecf8794b50e91aa1e73fc78bfb5503b5d9", size = 498580, upload-time = "2026-03-28T17:15:03.879Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/bbc989f5362066b81930da1a66084a859a971d03faab799dc59a3ce3a220/aiohttp-3.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:717d17347567ded1e273aa09918650dfd6fd06f461549204570c7973537d4123", size = 1692718, upload-time = "2026-03-28T17:15:05.541Z" }, + { url = "https://files.pythonhosted.org/packages/1c/72/3775116969931f151be116689d2ae6ddafff2ec2887d8f9b4e7043f32e74/aiohttp-3.13.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:383880f7b8de5ac208fa829c7038d08e66377283b2de9e791b71e06e803153c2", size = 1660714, upload-time = "2026-03-28T17:15:08.23Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e8/d2f1a2da2743e32fe348ebf8a4c59caad14a92f5f18af616fd33381275e1/aiohttp-3.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1867087e2c1963db1216aedf001efe3b129835ed2b05d97d058176a6d08b5726", size = 1744152, upload-time = "2026-03-28T17:15:10.828Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a6/575886f417ac3c08e462f2ca237cc49f436bd992ca3f7ff95b7dd9c44205/aiohttp-3.13.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6234bf416a38d687c3ab7f79934d7fb2a42117a5b9813aca07de0a5398489023", size = 1836278, upload-time = "2026-03-28T17:15:12.537Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4c/0051d4550fb9e8b5ca4e0fe1ccd58652340915180c5164999e6741bf2083/aiohttp-3.13.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdd3393130bf6588962441ffd5bde1d3ea2d63a64afa7119b3f3ba349cebbe7", size = 1687953, upload-time = "2026-03-28T17:15:14.248Z" }, + { url = "https://files.pythonhosted.org/packages/c9/54/841e87b8c51c2adc01a3ceb9919dc45c7899fe4c21deb70aada734ea5a38/aiohttp-3.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d0dbc6c76befa76865373d6aa303e480bb8c3486e7763530f7f6e527b471118", size = 1572484, upload-time = "2026-03-28T17:15:15.911Z" }, + { url = "https://files.pythonhosted.org/packages/da/f1/21cbf5f7fa1e267af6301f886cab9b314f085e4d0097668d189d165cd7da/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10fb7b53262cf4144a083c9db0d2b4d22823d6708270a9970c4627b248c6064c", size = 1662851, upload-time = "2026-03-28T17:15:17.822Z" }, + { url = "https://files.pythonhosted.org/packages/40/15/bcad6b68d7bef27ae7443288215767263c7753ede164267cf6cf63c94a87/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:eb10ce8c03850e77f4d9518961c227be569e12f71525a7e90d17bca04299921d", size = 1671984, upload-time = "2026-03-28T17:15:19.561Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/ab316931afc7a73c7f493bb1b30fbd61e28ec2d3ea50353336e76293e8ec/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7c65738ac5ae32b8feef699a4ed0dc91a0c8618b347781b7461458bbcaaac7eb", size = 1713880, upload-time = "2026-03-28T17:15:21.589Z" }, + { url = "https://files.pythonhosted.org/packages/1c/45/314e8e64c7f328174964b6db511dd5e9e60c9121ab5457bc2c908b7d03a4/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6b335919ffbaf98df8ff3c74f7a6decb8775882632952fd1810a017e38f15aee", size = 1560315, upload-time = "2026-03-28T17:15:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/18/e7/93d5fa06fe00219a81466577dacae9e3732f3b4f767b12b2e2cc8c35c970/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ec75fc18cb9f4aca51c2cbace20cf6716e36850f44189644d2d69a875d5e0532", size = 1735115, upload-time = "2026-03-28T17:15:25.77Z" }, + { url = "https://files.pythonhosted.org/packages/19/9f/f64b95392ddd4e204fd9ab7cd33dd18d14ac9e4b86866f1f6a69b7cda83d/aiohttp-3.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:463fa18a95c5a635d2b8c09babe240f9d7dbf2a2010a6c0b35d8c4dff2a0e819", size = 1673916, upload-time = "2026-03-28T17:15:27.526Z" }, + { url = "https://files.pythonhosted.org/packages/52/c1/bb33be79fd285c69f32e5b074b299cae8847f748950149c3965c1b3b3adf/aiohttp-3.13.4-cp310-cp310-win32.whl", hash = "sha256:13168f5645d9045522c6cef818f54295376257ed8d02513a37c2ef3046fc7a97", size = 440277, upload-time = "2026-03-28T17:15:29.173Z" }, + { url = "https://files.pythonhosted.org/packages/23/f9/7cf1688da4dd0885f914ee40bc8e1dce776df98fe6518766de975a570538/aiohttp-3.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:a7058af1f53209fdf07745579ced525d38d481650a989b7aa4a3b484b901cdab", size = 463015, upload-time = "2026-03-28T17:15:30.802Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7e/cb94129302d78c46662b47f9897d642fd0b33bdfef4b73b20c6ced35aa4c/aiohttp-3.13.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8ea0c64d1bcbf201b285c2246c51a0c035ba3bbd306640007bc5844a3b4658c1", size = 760027, upload-time = "2026-03-28T17:15:33.022Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cd/2db3c9397c3bd24216b203dd739945b04f8b87bb036c640da7ddb63c75ef/aiohttp-3.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6f742e1fa45c0ed522b00ede565e18f97e4cf8d1883a712ac42d0339dfb0cce7", size = 508325, upload-time = "2026-03-28T17:15:34.714Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/d28b2722ec13107f2e37a86b8a169897308bab6a3b9e071ecead9d67bd9b/aiohttp-3.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dcfb50ee25b3b7a1222a9123be1f9f89e56e67636b561441f0b304e25aaef8f", size = 502402, upload-time = "2026-03-28T17:15:36.409Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d6/acd47b5f17c4430e555590990a4746efbcb2079909bb865516892bf85f37/aiohttp-3.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3262386c4ff370849863ea93b9ea60fd59c6cf56bf8f93beac625cf4d677c04d", size = 1771224, upload-time = "2026-03-28T17:15:38.223Z" }, + { url = "https://files.pythonhosted.org/packages/98/af/af6e20113ba6a48fd1cd9e5832c4851e7613ef50c7619acdaee6ec5f1aff/aiohttp-3.13.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:473bb5aa4218dd254e9ae4834f20e31f5a0083064ac0136a01a62ddbae2eaa42", size = 1731530, upload-time = "2026-03-28T17:15:39.988Z" }, + { url = "https://files.pythonhosted.org/packages/81/16/78a2f5d9c124ad05d5ce59a9af94214b6466c3491a25fb70760e98e9f762/aiohttp-3.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56423766399b4c77b965f6aaab6c9546617b8994a956821cc507d00b91d978c", size = 1827925, upload-time = "2026-03-28T17:15:41.944Z" }, + { url = "https://files.pythonhosted.org/packages/2a/1f/79acf0974ced805e0e70027389fccbb7d728e6f30fcac725fb1071e63075/aiohttp-3.13.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8af249343fafd5ad90366a16d230fc265cf1149f26075dc9fe93cfd7c7173942", size = 1923579, upload-time = "2026-03-28T17:15:44.071Z" }, + { url = "https://files.pythonhosted.org/packages/af/53/29f9e2054ea6900413f3b4c3eb9d8331f60678ec855f13ba8714c47fd48d/aiohttp-3.13.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc0a5cf4f10ef5a2c94fdde488734b582a3a7a000b131263e27c9295bd682d9", size = 1767655, upload-time = "2026-03-28T17:15:45.911Z" }, + { url = "https://files.pythonhosted.org/packages/f3/57/462fe1d3da08109ba4aa8590e7aed57c059af2a7e80ec21f4bac5cfe1094/aiohttp-3.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c7ff1028e3c9fc5123a865ce17df1cb6424d180c503b8517afbe89aa566e6be", size = 1630439, upload-time = "2026-03-28T17:15:48.11Z" }, + { url = "https://files.pythonhosted.org/packages/d7/4b/4813344aacdb8127263e3eec343d24e973421143826364fa9fc847f6283f/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ba5cf98b5dcb9bddd857da6713a503fa6d341043258ca823f0f5ab7ab4a94ee8", size = 1745557, upload-time = "2026-03-28T17:15:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/d4/01/1ef1adae1454341ec50a789f03cfafe4c4ac9c003f6a64515ecd32fe4210/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d85965d3ba21ee4999e83e992fecb86c4614d6920e40705501c0a1f80a583c12", size = 1741796, upload-time = "2026-03-28T17:15:52.351Z" }, + { url = "https://files.pythonhosted.org/packages/22/04/8cdd99af988d2aa6922714d957d21383c559835cbd43fbf5a47ddf2e0f05/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:49f0b18a9b05d79f6f37ddd567695943fcefb834ef480f17a4211987302b2dc7", size = 1805312, upload-time = "2026-03-28T17:15:54.407Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/b48d5577338d4b25bbdbae35c75dbfd0493cb8886dc586fbfb2e90862239/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7f78cb080c86fbf765920e5f1ef35af3f24ec4314d6675d0a21eaf41f6f2679c", size = 1621751, upload-time = "2026-03-28T17:15:56.564Z" }, + { url = "https://files.pythonhosted.org/packages/bc/89/4eecad8c1858e6d0893c05929e22343e0ebe3aec29a8a399c65c3cc38311/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67a3ec705534a614b68bbf1c70efa777a21c3da3895d1c44510a41f5a7ae0453", size = 1826073, upload-time = "2026-03-28T17:15:58.489Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5c/9dc8293ed31b46c39c9c513ac7ca152b3c3d38e0ea111a530ad12001b827/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6630ec917e85c5356b2295744c8a97d40f007f96a1c76bf1928dc2e27465393", size = 1760083, upload-time = "2026-03-28T17:16:00.677Z" }, + { url = "https://files.pythonhosted.org/packages/1e/19/8bbf6a4994205d96831f97b7d21a0feed120136e6267b5b22d229c6dc4dc/aiohttp-3.13.4-cp311-cp311-win32.whl", hash = "sha256:54049021bc626f53a5394c29e8c444f726ee5a14b6e89e0ad118315b1f90f5e3", size = 439690, upload-time = "2026-03-28T17:16:02.902Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f5/ac409ecd1007528d15c3e8c3a57d34f334c70d76cfb7128a28cffdebd4c1/aiohttp-3.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:c033f2bc964156030772d31cbf7e5defea181238ce1f87b9455b786de7d30145", size = 463824, upload-time = "2026-03-28T17:16:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bd/ede278648914cabbabfdf95e436679b5d4156e417896a9b9f4587169e376/aiohttp-3.13.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360", size = 752158, upload-time = "2026-03-28T17:16:06.901Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/581c053253c07b480b03785196ca5335e3c606a37dc73e95f6527f1591fe/aiohttp-3.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d", size = 501037, upload-time = "2026-03-28T17:16:08.82Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/a5ede193c08f13cc42c0a5b50d1e246ecee9115e4cf6e900d8dbd8fd6acb/aiohttp-3.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c", size = 501556, upload-time = "2026-03-28T17:16:10.63Z" }, + { url = "https://files.pythonhosted.org/packages/d6/10/88ff67cd48a6ec36335b63a640abe86135791544863e0cfe1f065d6cef7a/aiohttp-3.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97", size = 1757314, upload-time = "2026-03-28T17:16:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/8b/15/fdb90a5cf5a1f52845c276e76298c75fbbcc0ac2b4a86551906d54529965/aiohttp-3.13.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576", size = 1731819, upload-time = "2026-03-28T17:16:14.558Z" }, + { url = "https://files.pythonhosted.org/packages/ec/df/28146785a007f7820416be05d4f28cc207493efd1e8c6c1068e9bdc29198/aiohttp-3.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab", size = 1793279, upload-time = "2026-03-28T17:16:16.594Z" }, + { url = "https://files.pythonhosted.org/packages/10/47/689c743abf62ea7a77774d5722f220e2c912a77d65d368b884d9779ef41b/aiohttp-3.13.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d", size = 1891082, upload-time = "2026-03-28T17:16:18.71Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/f7f4f318c7e58c23b761c9b13b9a3c9b394e0f9d5d76fbc6622fa98509f6/aiohttp-3.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e", size = 1773938, upload-time = "2026-03-28T17:16:21.125Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/f207cb3121852c989586a6fc16ff854c4fcc8651b86c5d3bd1fc83057650/aiohttp-3.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3", size = 1579548, upload-time = "2026-03-28T17:16:23.588Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/e1289661a32161e24c1fe479711d783067210d266842523752869cc1d9c2/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83", size = 1714669, upload-time = "2026-03-28T17:16:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/96/0a/3e86d039438a74a86e6a948a9119b22540bae037d6ba317a042ae3c22711/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763", size = 1754175, upload-time = "2026-03-28T17:16:28.18Z" }, + { url = "https://files.pythonhosted.org/packages/f4/30/e717fc5df83133ba467a560b6d8ef20197037b4bb5d7075b90037de1018e/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9", size = 1762049, upload-time = "2026-03-28T17:16:30.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/28/8f7a2d4492e336e40005151bdd94baf344880a4707573378579f833a64c1/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758", size = 1570861, upload-time = "2026-03-28T17:16:32.953Z" }, + { url = "https://files.pythonhosted.org/packages/78/45/12e1a3d0645968b1c38de4b23fdf270b8637735ea057d4f84482ff918ad9/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9", size = 1790003, upload-time = "2026-03-28T17:16:35.468Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/60374e18d590de16dcb39d6ff62f39c096c1b958e6f37727b5870026ea30/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d", size = 1737289, upload-time = "2026-03-28T17:16:38.187Z" }, + { url = "https://files.pythonhosted.org/packages/02/bf/535e58d886cfbc40a8b0013c974afad24ef7632d645bca0b678b70033a60/aiohttp-3.13.4-cp312-cp312-win32.whl", hash = "sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791", size = 434185, upload-time = "2026-03-28T17:16:40.735Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1a/d92e3325134ebfff6f4069f270d3aac770d63320bd1fcd0eca023e74d9a8/aiohttp-3.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77", size = 461285, upload-time = "2026-03-28T17:16:42.713Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" }, + { url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" }, + { url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" }, + { url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" }, + { url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" }, + { url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" }, + { url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" }, + { url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" }, + { url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" }, + { url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" }, + { url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" }, + { url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" }, + { url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" }, + { url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" }, + { url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" }, + { url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" }, + { url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" }, + { url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" }, + { url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" }, + { url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" }, + { url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" }, + { url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" }, + { url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" }, + { url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" }, + { url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" }, + { url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" }, + { url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" }, + { url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" }, ] [[package]]