From da7dac83010aeb680d1a924cd7bb8f073b5fabf4 Mon Sep 17 00:00:00 2001 From: Templeton Face Date: Mon, 8 Jun 2026 18:29:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(vpn):=20Proton=20VPN=20Hermes=20skill=20?= =?UTF-8?q?=E2=80=94=20CLI=20wrapper=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the proton-vpn skill per ARCHITECTURE.md section 6 with 9 tools: Tools: - proton_vpn_connect — connect with fastest/random/country/city/P2P/Tor/SC selection - proton_vpn_disconnect — disconnect current session - proton_vpn_status — check connection status (parse CLI output) - proton_vpn_servers — list servers with filters (country, features) - proton_vpn_killswitch — enable/disable kill switch - proton_vpn_config — view/modify DNS, NetShield, protocol - proton_vpn_login — initiate browser OAuth login - proton_vpn_logout — clear credentials - proton_vpn_refresh — refresh server list and config Implementation: - Python subprocess wrapper around official protonvpn-cli v1.0+ - Human-readable CLI output parsed into structured JSON - Privilege check (protonvpn group) before privileged operations - 30-60s timeouts with graceful error handling - dispatch() entry point for Hermes tool routing Also includes: - scripts/install.sh — distro-aware dependency installer - references/commands.md — CLI quick reference - .gitignore — exclude __pycache__, env, debug files Deviations from ARCHITECTURE.md noted in docs: - CLI uses 'login' (browser OAuth), not 'init' - No --json output — parsed from tables - Install via Proton repos, not PyPI --- .gitignore | 26 + skills/proton-vpn/SKILL.md | 406 +++++++++++++ skills/proton-vpn/references/commands.md | 102 ++++ skills/proton-vpn/scripts/install.sh | 186 ++++++ skills/proton-vpn/scripts/tools.py | 711 +++++++++++++++++++++++ skills/proton-vpn/scripts/verify.py | 23 + 6 files changed, 1454 insertions(+) create mode 100644 .gitignore create mode 100644 skills/proton-vpn/SKILL.md create mode 100644 skills/proton-vpn/references/commands.md create mode 100755 skills/proton-vpn/scripts/install.sh create mode 100644 skills/proton-vpn/scripts/tools.py create mode 100644 skills/proton-vpn/scripts/verify.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3eb2d69 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.egg-info/ +dist/ +build/ + +# Environment +.env +venv/ +.venv/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Debug +debug_*.py +tmp/ diff --git a/skills/proton-vpn/SKILL.md b/skills/proton-vpn/SKILL.md new file mode 100644 index 0000000..c07fd0c --- /dev/null +++ b/skills/proton-vpn/SKILL.md @@ -0,0 +1,406 @@ +--- +name: proton-vpn +description: Proton VPN management — connect, disconnect, status, server list, kill switch, and config via the official protonvpn-cli +version: 1.0.0 +category: infra +platforms: [linux] +dependencies: + - protonvpn-cli (official Proton VPN Linux CLI) + - systemd-resolved (for DNS leak protection) + - gnome-keyring (for credential storage) + - NetworkManager (for connection management) +metadata: + hermes: + tags: [vpn, proton, network, privacy, security] + auth: independent — protonvpn-cli manages its own OAuth session + limitations: + - "Does NOT work on headless setups (requires gnome-keyring + NetworkManager)" + - "Cannot run alongside Proton VPN GUI app" + - "Split tunneling not yet available in CLI v1.0.1" +--- +# Proton VPN Hermes Skill + +A Hermes skill that wraps the official [Proton VPN Linux CLI](https://github.com/ProtonVPN/proton-vpn-cli) (`protonvpn-cli`) for agent use. Provides full VPN lifecycle management — connect, disconnect, status, server discovery, kill switch, and configuration. + +## Installation + +### Prerequisites + +The skill shells out to `protonvpn-cli` v1.0.0+. Install it via the official Proton repositories: + +```bash +# Debian/Ubuntu (add Proton repo first) +curl -1sLf 'https://repo.protonvpn.com/debian/dists/stable/main/signed.key' | sudo apt-key add - +sudo add-apt-repository 'deb https://repo.protonvpn.com/debian stable main' +sudo apt update && sudo apt install protonvpn-cli + +# Fedora +sudo dnf install protonvpn-cli + +# Arch (AUR) +yay -S protonvpn-cli +``` + +### Post-Install Setup + +1. **Initial login** (requires a desktop session — browser OAuth): + ```bash + protonvpn-cli login + ``` + First-time login opens a browser for OAuth. After that, the session persists. + +2. **Verify installation**: + ```bash + protonvpn-cli status + protonvpn-cli servers | head -10 + ``` + +3. **Ensure required services**: + ```bash + systemctl status systemd-resolved # should be running + ``` + +## Tools + +### `proton_vpn_connect` + +Connect to a Proton VPN server. + +```json +{ + "name": "proton_vpn_connect", + "description": "Connect to a Proton VPN server. Supports fastest, random, country, city, P2P, Tor, Secure Core, and free server selection.", + "parameters": { + "type": "object", + "properties": { + "server": { + "type": "string", + "description": "Server name or ID (e.g., 'US-NY#1'). Omitting uses default selection strategy." + }, + "selection": { + "type": "string", + "enum": ["fastest", "random", "country", "city", "p2p", "tor", "free", "secure-core"], + "description": "Server selection strategy. 'fastest' (default): lowest latency. 'random': random server. 'country': fastest in specified country. 'p2p'/'tor'/'secure-core': feature-optimized." + }, + "country": { + "type": "string", + "description": "Two-letter country code (e.g., 'US', 'CH', 'JP'). Only used when selection='country'." + }, + "city": { + "type": "string", + "description": "City name (e.g., 'New York'). Only used when selection='city'." + }, + "protocol": { + "type": "string", + "enum": ["wireguard", "openvpn_udp", "openvpn_tcp"], + "description": "VPN protocol. Default is WireGuard when available." + }, + "persistent": { + "type": "boolean", + "description": "Auto-reconnect if the VPN connection drops." + } + } + } +} +``` + +**CLI mapping:** +- Fastest: `protonvpn-cli connect --fastest` +- Random: `protonvpn-cli connect --random` +- Country: `protonvpn-cli connect --country US --protocol wireguard` +- P2P: `protonvpn-cli connect --p2p` +- Server name: `protonvpn-cli connect US-NY#1` + +**Returns:** JSON with connection status, server name, protocol, and IP. + +--- + +### `proton_vpn_disconnect` + +Disconnect the current VPN session. + +```json +{ + "name": "proton_vpn_disconnect", + "description": "Disconnect the current VPN session and restore normal network connectivity.", + "parameters": {} +} +``` + +**CLI mapping:** `protonvpn-cli disconnect` + +**Returns:** JSON with confirmation message. + +--- + +### `proton_vpn_status` + +Get current VPN connection status. + +```json +{ + "name": "proton_vpn_status", + "description": "Check the current Proton VPN connection status — connected server, protocol, uptime, IP information.", + "parameters": {} +} +``` + +**CLI mapping:** `protonvpn-cli status` + +**Returns:** JSON with connection state, server name, country, protocol, uptime, and local IP. + +--- + +### `proton_vpn_servers` + +List available Proton VPN servers with features and load. + +```json +{ + "name": "proton_vpn_servers", + "description": "List available Proton VPN servers. Shows country, city, current load percentage, and supported features (P2P, Tor, Secure Core).", + "parameters": { + "type": "object", + "properties": { + "country": { + "type": "string", + "description": "Filter by two-letter country code (e.g., 'US')." + }, + "features": { + "type": "array", + "items": { "type": "string", "enum": ["p2p", "tor", "secure-core", "free"] }, + "description": "Filter by required features." + }, + "format": { + "type": "string", + "enum": ["table", "json"], + "description": "Output format. 'table': human-readable. 'json': machine-parseable." + } + } + } +} +``` + +**CLI mapping:** `protonvpn-cli servers` (table output, parsed to JSON) + +**Returns:** JSON array of servers with name, country, city, load, features. + +--- + +### `proton_vpn_killswitch` + +Enable or disable the VPN kill switch. + +```json +{ + "name": "proton_vpn_killswitch", + "description": "Enable or disable the VPN kill switch. When enabled, all internet traffic is blocked outside the VPN tunnel, preventing data leaks if the VPN drops.", + "parameters": { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": ["on", "off"], + "description": "Kill switch state. 'on': enable (blocks non-VPN traffic). 'off': disable." + } + }, + "required": ["state"] + } +} +``` + +**CLI mapping:** `protonvpn-cli settings --killswitch on|off` + +**Returns:** JSON with kill switch state and confirmation. + +--- + +### `proton_vpn_config` + +View or modify VPN configuration. + +```json +{ + "name": "proton_vpn_config", + "description": "View current Proton VPN configuration or modify settings like DNS, NetShield, protocol preference, and VPN Accelerator.", + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["view", "set-dns", "set-netshield", "set-protocol"], + "description": "'view' (default): show current config. 'set-dns': set custom DNS servers. 'set-netshield': toggle NetShield ad-blocker. 'set-protocol': set preferred protocol." + }, + "value": { + "type": "string", + "description": "Value for the setting. For 'set-dns': comma-separated IPs (e.g., '1.1.1.1,1.0.0.1'). For 'set-netshield': 'on', 'off', or 'strict'. For 'set-protocol': 'wireguard', 'openvpn_udp', or 'openvpn_tcp'." + } + } + } +} +``` + +**CLI mapping:** +- View: `protonvpn-cli config --list` +- Kill switch: `protonvpn-cli settings --killswitch on|off` +- NetShield: `protonvpn-cli settings --netshield on|off|strict` +- DNS: `protonvpn-cli settings --custom-dns ` + +**Returns:** JSON with configuration key-value pairs or confirmation message. + +--- + +### `proton_vpn_login` + +Authenticate with Proton VPN (browser OAuth). + +```json +{ + "name": "proton_vpn_login", + "description": "Authenticate with Proton VPN. Requires a desktop session to open a browser for OAuth login. Proton VPN CLI manages credentials after initial login.", + "parameters": { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "Proton account username/email." + } + } + } +} +``` + +**CLI mapping:** `protonvpn-cli login [username]` + +**Note:** First-time login requires a browser for OAuth. The agent should warn users that this won't work in headless environments. + +--- + +### `proton_vpn_logout` + +Log out from Proton VPN. + +```json +{ + "name": "proton_vpn_logout", + "description": "Log out from Proton VPN. Clears stored credentials and disconnects if connected.", + "parameters": {} +} +``` + +**CLI mapping:** `protonvpn-cli logout` + +--- + +### `proton_vpn_refresh` + +Refresh server list and VPN configuration. + +```json +{ + "name": "proton_vpn_refresh", + "description": "Refresh the server list and VPN configuration from Proton's API. Useful after a network change or if servers appear outdated.", + "parameters": {} +} +``` + +**CLI mapping:** `protonvpn-cli refresh` + +--- + +## Implementation + +All tools shell out to `protonvpn-cli` via Python `subprocess`. The implementation module lives at `scripts/tools.py` in this skill directory. + +### General Pattern + +```python +import subprocess +import json +import shlex + +def _run_vpn_command(args: list[str], timeout: int = 30) -> dict: + """Run a protonvpn-cli command and return structured output.""" + try: + result = subprocess.run( + ["protonvpn-cli"] + args, + capture_output=True, text=True, timeout=timeout + ) + if result.returncode != 0: + return {"error": result.stderr.strip(), "exit_code": result.returncode} + return { + "success": True, + "output": result.stdout.strip(), + "command": "protonvpn-cli " + " ".join(shlex.quote(a) for a in args) + } + except subprocess.TimeoutExpired: + return {"error": f"Command timed out after {timeout}s"} + except FileNotFoundError: + return {"error": "protonvpn-cli not found. Install via Proton repos."} +``` + +### Privilege Check + +`protonvpn-cli` requires either root or membership in the `protonvpn` group for WireGuard interface creation. All tools should call a privilege check before executing: + +```python +import os + +def _check_vpn_privileges() -> dict | None: + """Check if the current user can use protonvpn-cli. Returns error dict or None.""" + if os.geteuid() == 0: + return None # root can always use it + import subprocess + groups_result = subprocess.run(["groups"], capture_output=True, text=True) + groups = groups_result.stdout.strip() + if "protonvpn" not in groups: + return { + "error": ( + "User not in 'protonvpn' group. " + "Run: sudo usermod -aG protonvpn $USER && logout && login" + ) + } + return None +``` + +### Output Parsing + +The CLI outputs human-readable tables (not JSON). The tool parses output into structured JSON: + +- `protonvpn-cli status` → parsed into connection state, server, protocol, uptime, IP +- `protonvpn-cli servers` → parsed into array of server objects +- `protonvpn-cli config --list` → parsed into key-value pairs + +## Dependencies + +| Dependency | Required | Notes | +|------------|----------|-------| +| `protonvpn-cli` (>=1.0.0) | Yes | Official Proton VPN CLI, v1.0.1 latest (Apr 2026). [GitHub](https://github.com/ProtonVPN/proton-vpn-cli) | +| `systemd-resolved` | Recommended | DNS leak protection via systemd-resolved | +| `gnome-keyring` | Recommended | Credential storage for initial login | +| `NetworkManager` | Required | VPN connection profiles managed via NetworkManager | +| `WireGuard` | Recommended | Default protocol; faster than OpenVPN | +| `OpenVPN` | Fallback | Alternative protocol when WireGuard unavailable | + +## Limitations + +1. **No headless support** — The CLI requires `gnome-keyring` and `NetworkManager`. It does not work on minimal server installs without a desktop environment. +2. **Cannot coexist with Proton VPN GUI** — Running both simultaneously causes conflicts. +3. **Split tunneling not yet available** — Feature is planned for a future release. +4. **Requires internet for login** — OAuth flow needs a browser. +5. **Kill switch uses iptables** — May conflict with other firewall rules. Verify carefully. + +## Security + +- Proton VPN CLI stores credentials in `gnome-keyring` (encrypted at rest) +- Connection logs: `~/.cache/Proton/VPN/logs/` +- Configuration: `~/.config/Proton/VPN/` +- The skill passes connection results and server lists back to the agent; no credentials are exposed +- Kill switch is system-level (iptables rules), not agent-level + +## Related + +- [Official Proton VPN CLI GitHub](https://github.com/ProtonVPN/proton-vpn-cli) +- [Proton VPN Linux CLI support page](https://protonvpn.com/support/linux-cli) +- [Hermes hardware keychain skill](https://github.com/NousResearch/hermes-agent) (example of subprocess skill pattern) +- [ARCHITECTURE.md](../../ARCHITECTURE.md) — Hermes-Proton integration design (section 6) diff --git a/skills/proton-vpn/references/commands.md b/skills/proton-vpn/references/commands.md new file mode 100644 index 0000000..f92672b --- /dev/null +++ b/skills/proton-vpn/references/commands.md @@ -0,0 +1,102 @@ +# protonvpn-cli Reference — Commands Quick Reference + +> Official Proton VPN Linux CLI (v1.0.0+) +> Source: https://github.com/ProtonVPN/proton-vpn-cli +> Support: https://protonvpn.com/support/linux-cli + +## Installation + +```bash +# Debian/Ubuntu +curl -fsSL 'https://repo.protonvpn.com/debian/dists/stable/main/signed.key' | \ + sudo gpg --dearmor -o /usr/share/keyrings/protonvpn.gpg +echo "deb [signed-by=/usr/share/keyrings/protonvpn.gpg] https://repo.protonvpn.com/debian stable main" | \ + sudo tee /etc/apt/sources.list.d/protonvpn.list +sudo apt update && sudo apt install protonvpn-cli + +# Fedora +sudo dnf install protonvpn-cli + +# Arch (AUR) +yay -S protonvpn-cli +``` + +## Login / Logout + +| Command | Description | +|---------|-------------| +| `protonvpn-cli login [username]` | Authenticate via browser OAuth | +| `protonvpn-cli logout` | Clear credentials | + +## Connect / Disconnect + +| Command | Description | +|---------|-------------| +| `protonvpn-cli connect [servername]` | Connect to specific server | +| `protonvpn-cli connect --fastest` | Connect to lowest-latency server | +| `protonvpn-cli connect --random` | Connect to random server | +| `protonvpn-cli connect --country US` | Connect to fastest server in country | +| `protonvpn-cli connect --city "New York"` | Connect to fastest server in city | +| `protonvpn-cli connect --p2p` | Connect to fastest P2P server | +| `protonvpn-cli connect --tor` | Connect to fastest Tor server | +| `protonvpn-cli connect --free` | Connect to free server | +| `protonvpn-cli connect --secure-core` | Connect to Secure Core server | +| `protonvpn-cli connect --protocol wireguard` | Specify protocol | +| `protonvpn-cli connect --persistent` | Auto-reconnect if VPN drops | +| `protonvpn-cli disconnect` | Disconnect current session | + +All connect options can be combined with `-p udp` or `-p tcp` to specify protocol +(for OpenVPN mode). + +## Status & Info + +| Command | Description | +|---------|-------------| +| `protonvpn-cli status` | Show connection status, server, uptime, IP | +| `protonvpn-cli servers` | List all servers with features and load | + +## Settings & Config + +| Command | Description | +|---------|-------------| +| `protonvpn-cli config --list` | Show current configuration | +| `protonvpn-cli settings --killswitch on` | Enable kill switch | +| `protonvpn-cli settings --killswitch off` | Disable kill switch | +| `protonvpn-cli settings --netshield on` | Enable NetShield (block malware) | +| `protonvpn-cli settings --netshield off` | Disable NetShield | +| `protonvpn-cli settings --netshield strict` | Strict NetShield mode | +| `protonvpn-cli settings --custom-dns 1.1.1.1` | Set custom DNS | +| `protonvpn-cli settings --protocol wireguard` | Set preferred protocol | +| `protonvpn-cli settings --dnsleak-protection on` | Enable DNS leak protection | +| `protonvpn-cli settings --dnsleak-protection off` | Disable DNS leak protection | +| `protonvpn-cli refresh` | Refresh server list and config | + +## General + +| Command | Description | +|---------|-------------| +| `protonvpn-cli --help` | Show help | +| `protonvpn-cli --version` | Show version | +| `protonvpn-cli --debug` | Enable verbose debug logging | + +## Logs & Files + +- **Logs:** `~/.cache/Proton/VPN/logs/` +- **Config:** `~/.config/Proton/VPN/` +- **Settings DB:** `~/.config/Proton/VPN/settings.json` + +## Requirements + +- Python 3 +- systemd-resolved (for DNS leak protection) +- gnome-keyring (for credential storage) +- NetworkManager (for connection profiles) +- WireGuard kernel module (preferred protocol) or OpenVPN + +## Known Limitations (v1.0.1) + +- No headless support (requires gnome-keyring + NetworkManager) +- Cannot run alongside Proton VPN GUI app +- Split tunneling is not yet available +- First login requires a browser for OAuth +- Kill switch uses iptables (may conflict with other firewall rules) diff --git a/skills/proton-vpn/scripts/install.sh b/skills/proton-vpn/scripts/install.sh new file mode 100755 index 0000000..982ba4f --- /dev/null +++ b/skills/proton-vpn/scripts/install.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# +# install.sh — Proton VPN CLI dependency installer for Hermes proton-vpn skill +# +# Installs the official Proton VPN Linux CLI on Debian/Ubuntu, Fedora, or Arch. +# Run as root or with sudo. +# +# Usage: +# sudo ./install.sh # auto-detect distro and install +# sudo ./install.sh --check # only check if already installed +# + +set -euo pipefail + +# ── colors ───────────────────────────────────────────────────────────── + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +info() { echo -e "${BLUE}[INFO]${NC} $*"; } +ok() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +err() { echo -e "${RED}[ERROR]${NC} $*"; } + +# ── check if installed ───────────────────────────────────────────────── + +check_installed() { + if command -v protonvpn-cli &>/dev/null; then + local version + version=$(protonvpn-cli --version 2>/dev/null || echo "unknown") + ok "protonvpn-cli is already installed (version: $version)" + return 0 + fi + return 1 +} + +# ── detect distro ────────────────────────────────────────────────────── + +detect_distro() { + if [ -f /etc/os-release ]; then + . /etc/os-release + echo "$ID" + elif command -v lsb_release &>/dev/null; then + lsb_release -is | tr '[:upper:]' '[:lower:]' + else + echo "unknown" + fi +} + +# ── Debian/Ubuntu install ────────────────────────────────────────────── + +install_debian() { + info "Adding Proton VPN repository..." + + # Install prerequisites + apt-get update -qq + apt-get install -y -qq curl gnupg ca-certificates + + # Add Proton VPN repo key and source + curl -fsSL 'https://repo.protonvpn.com/debian/dists/stable/main/signed.key' | \ + gpg --dearmor -o /usr/share/keyrings/protonvpn.gpg + + cat > /etc/apt/sources.list.d/protonvpn.list </dev/null; then + err "yay (AUR helper) not found. Install yay first or use:" + err " git clone https://aur.archlinux.org/protonvpn-cli.git" + err " cd protonvpn-cli && makepkg -si" + exit 1 + fi + yay -S --noconfirm protonvpn-cli + ok "protonvpn-cli installed via AUR" +} + +# ── post-install setup ──────────────────────────────────────────────── + +post_install() { + info "Performing post-install setup..." + + # Add user to protonvpn group (so CLI works without sudo) + if [ -n "${SUDO_USER:-}" ]; then + usermod -aG protonvpn "$SUDO_USER" 2>/dev/null || true + warn "User '$SUDO_USER' added to 'protonvpn' group." + warn "Log out and back in for group membership to take effect." + fi + + # Check systemd-resolved + if systemctl is-active --quiet systemd-resolved 2>/dev/null; then + ok "systemd-resolved is running" + else + warn "systemd-resolved is not running. DNS leak protection may not work." + warn " Enable: sudo systemctl enable --now systemd-resolved" + fi + + # Check NetworkManager + if systemctl is-active --quiet NetworkManager 2>/dev/null; then + ok "NetworkManager is running" + else + warn "NetworkManager is not running. protonvpn-cli requires it." + fi + + # Check gnome-keyring + if command -v gnome-keyring-daemon &>/dev/null; then + ok "gnome-keyring found" + else + warn "gnome-keyring not found. Install it for credential storage." + warn " sudo apt install gnome-keyring # Debian/Ubuntu" + warn " sudo dnf install gnome-keyring # Fedora" + fi + + echo "" + info "Setup complete! Run 'protonvpn-cli login' to authenticate." + info "Then try: protonvpn-cli status" +} + +# ── main ─────────────────────────────────────────────────────────────── + +main() { + echo "" + echo " ┌─────────────────────────────────────┐" + echo " │ Proton VPN CLI — Hermes Skill Setup │" + echo " └─────────────────────────────────────┘" + echo "" + + if [ "$1" = "--check" ]; then + check_installed + exit $? + fi + + if check_installed; then + post_install + exit 0 + fi + + if [ "$(id -u)" -ne 0 ]; then + err "This script must be run as root (sudo ./install.sh)" + exit 1 + fi + + distro=$(detect_distro) + info "Detected distribution: $distro" + + case "$distro" in + debian|ubuntu|linuxmint|pop|elementary|kali|zorin|mx) + install_debian + ;; + fedora|rhel|centos) + install_fedora + ;; + arch|manjaro|endeavouros|artix) + install_arch + ;; + *) + err "Unsupported distro: $distro" + err "Manual installation: see https://protonvpn.com/support/linux-cli" + exit 1 + ;; + esac + + post_install +} + +main "${1:-install}" diff --git a/skills/proton-vpn/scripts/tools.py b/skills/proton-vpn/scripts/tools.py new file mode 100644 index 0000000..d0a0099 --- /dev/null +++ b/skills/proton-vpn/scripts/tools.py @@ -0,0 +1,711 @@ +#!/usr/bin/env python3 +""" +Proton VPN Hermes skill — tool implementations. + +All tools shell out to the official `protonvpn-cli` binary and parse +human-readable CLI output into structured JSON for agent consumption. + +Requires: protonvpn-cli >= 1.0.0 (installed via Proton repos) +See SKILL.md for installation and setup instructions. +""" + +import json +import os +import re +import shlex +import subprocess +from datetime import datetime +from typing import Any + + +# ── helpers ──────────────────────────────────────────────────────────── + + +def _run_vpn(args: list[str], timeout: int = 30) -> dict: + """Run a `protonvpn-cli` command and return a structured result dict.""" + cmd = ["protonvpn-cli"] + args + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout + ) + out = result.stdout.strip() + err = result.stderr.strip() + + if result.returncode != 0: + # The CLI often prints errors to stdout as well + return { + "success": False, + "exit_code": result.returncode, + "error": err or out, + "command": " ".join(shlex.quote(s) for s in cmd), + } + + return { + "success": True, + "output": out, + "command": " ".join(shlex.quote(s) for s in cmd), + } + except subprocess.TimeoutExpired: + return { + "success": False, + "error": f"Command timed out after {timeout}s: {' '.join(shlex.quote(s) for s in cmd)}", + } + except FileNotFoundError: + return { + "success": False, + "error": ( + "protonvpn-cli not found. Install via Proton repos:\n" + " https://protonvpn.com/support/linux-cli" + ), + } + except OSError as exc: + return { + "success": False, + "error": str(exc), + } + + +def _check_vpn_privileges() -> dict | None: + """ + Check that the current user can use protonvpn-cli. + + Returns an error dict if the user lacks privileges, or None if OK. + """ + if os.geteuid() == 0: + return None # root can always run VPN commands + + try: + groups_out = subprocess.run( + ["groups"], capture_output=True, text=True, timeout=5 + ).stdout.strip() + except Exception as exc: + return {"success": False, "error": f"Cannot check groups: {exc}"} + + if "protonvpn" not in groups_out: + return { + "success": False, + "error": ( + "User is not in the 'protonvpn' group. " + "Run: sudo usermod -aG protonvpn $USER && log out and back in." + ), + } + return None + + +def _privilege_guard() -> dict | None: + """Run privilege check and return a structured error if it fails.""" + err = _check_vpn_privileges() + if err is not None: + return err + return None + + +# ── output parsers ───────────────────────────────────────────────────── + + +def _parse_status(raw: str) -> dict: + """ + Parse `protonvpn-cli status` output. + + Typical output: + Status: Connected + Server: US-NY#123 + Country: United States + City: New York + Protocol: WireGuard + Uptime: 47m 32s + Local IP: 192.168.1.42 + Public IP: 10.2.3.4 + """ + result = {"connected": False} + for line in raw.splitlines(): + line = line.strip() + if not line or ":" not in line: + continue + + key, _, value = line.partition(":") + key = key.strip().lower().replace(" ", "_") + value = value.strip() + + if key == "status": + result["connected"] = value.lower() == "connected" + result["status"] = value + elif key == "server": + result["server_name"] = value + elif key == "country": + result["country"] = value + elif key == "city": + result["city"] = value + elif key == "protocol": + result["protocol"] = value + elif key == "uptime": + result["uptime"] = value + elif key == "local_ip": + result["local_ip"] = value + elif key == "public_ip": + result["public_ip"] = value + + return result + + +def _parse_servers(raw: str) -> list[dict]: + """ + Parse `protonvpn-cli servers` output (table format). + + The CLI outputs a table with columns like: + Server Name Country City Load Features + ───────────────────────────────────────────────────────────────── + US-NY#123 United States New York 47% P2P + CH-ZR#45 Switzerland Zurich 12% Tor + JP-TK#78 Japan Tokyo 89% Secure Core + + Note: the exact format may vary by version. This parser is heuristic. + """ + lines = raw.splitlines() + servers = [] + header_found = False + + for line in lines: + # Skip separators + if re.match(r"^[─\-\s]+$", line): + continue + # Skip empty lines and banner + if not line.strip() or line.strip().startswith("Server"): + header_found = True + continue + + if not header_found: + continue + + # Split on 2+ spaces (table columns) + parts = re.split(r"\s{2,}", line.strip()) + if len(parts) < 3: + continue + + entry = { + "name": parts[0].strip(), + "country": parts[1].strip() if len(parts) > 1 else "", + "city": parts[2].strip() if len(parts) > 2 else "", + } + + if len(parts) > 3: + load_str = parts[3].strip().rstrip("%") + try: + entry["load_percent"] = int(load_str) + except ValueError: + entry["load_percent"] = None + else: + entry["load_percent"] = None + + if len(parts) > 4: + features = [f.strip().lower() for f in parts[4].split(",") if f.strip()] + entry["features"] = features + else: + entry["features"] = [] + + servers.append(entry) + + return servers + + +def _parse_config(raw: str) -> dict: + """ + Parse `protonvpn-cli config --list` or `protonvpn-cli settings` output. + + Lines like: + Kill switch: enabled + NetShield: disabled + DNS leak protection: enabled + Protocol preference: wireguard + """ + config = {} + for line in raw.splitlines(): + if ":" not in line: + continue + key, _, value = line.partition(":") + config[key.strip().lower().replace(" ", "_")] = value.strip() + return config + + +# ── tool handlers ────────────────────────────────────────────────────── + + +def proton_vpn_login(args: dict) -> dict: + """ + Authenticate with Proton VPN. + + NOTE: This opens a browser for OAuth and requires a desktop session. + It will not work in headless environments. + """ + username = args.get("username", "") + cmd_args = ["login"] + if username: + cmd_args.append(username) + + result = _run_vpn(cmd_args, timeout=60) # login can be slow (browser wait) + + if not result["success"]: + return result + + return { + "success": True, + "message": "Login initiated. Follow the browser prompt to authenticate.", + "output": result["output"], + "note": ( + "Proton VPN CLI uses browser-based OAuth. " + "This requires a desktop session with a browser available." + ), + } + + +def proton_vpn_logout(args: dict) -> dict: + """Log out from Proton VPN, clearing stored credentials.""" + priv = _privilege_guard() + if priv: + return priv + + result = _run_vpn(["logout"]) + + if not result["success"]: + return result + + return { + "success": True, + "message": "Logged out of Proton VPN. Credentials cleared.", + } + + +def proton_vpn_connect(args: dict) -> dict: + """ + Connect to a Proton VPN server. + + Supports: fastest (default), random, country, city, P2P, Tor, Secure Core. + """ + priv = _privilege_guard() + if priv: + return priv + + cmd_args = ["connect"] + selection = args.get("selection", "fastest") + + # Server name takes precedence + server_name = args.get("server") + if server_name: + cmd_args.append(server_name) + elif selection == "fastest": + cmd_args.append("--fastest") + elif selection == "random": + cmd_args.append("--random") + elif selection == "country": + country = args.get("country") + if not country: + return {"success": False, "error": "country parameter required when selection='country'"} + cmd_args.append(f"--country={country}") + elif selection == "city": + city = args.get("city") + if not city: + return {"success": False, "error": "city parameter required when selection='city'"} + cmd_args.append(f"--city={city}") + elif selection == "p2p": + cmd_args.append("--p2p") + elif selection == "tor": + cmd_args.append("--tor") + elif selection == "free": + cmd_args.append("--free") + elif selection == "secure-core": + cmd_args.append("--secure-core") + + # Optional protocol + protocol = args.get("protocol") + if protocol: + cmd_args.extend(["--protocol", protocol]) + + # Optional persistent flag + if args.get("persistent"): + cmd_args.append("--persistent") + + result = _run_vpn(cmd_args, timeout=45) + + if not result["success"]: + return result + + return { + "success": True, + "action": "connect", + "selection": selection, + "message": result["output"], + } + + +def proton_vpn_disconnect(args: dict) -> dict: + """Disconnect the current VPN session.""" + priv = _privilege_guard() + if priv: + return priv + + result = _run_vpn(["disconnect"]) + + if not result["success"]: + return result + + return { + "success": True, + "action": "disconnect", + "message": "VPN disconnected.", + "output": result["output"], + } + + +def proton_vpn_status(args: dict) -> dict: + """Get current VPN connection status.""" + result = _run_vpn(["status"]) + + if not result["success"]: + return result + + parsed = _parse_status(result["output"]) + return { + "success": True, + **parsed, + } + + +def proton_vpn_servers(args: dict) -> dict: + """List available Proton VPN servers.""" + result = _run_vpn(["servers"], timeout=60) + + if not result["success"]: + return result + + servers = _parse_servers(result["output"]) + + # Apply filters + country_filter = args.get("country") + if country_filter: + country_upper = country_filter.upper() + servers = [s for s in servers if s.get("country", "").upper() == country_upper] + + features_filter = args.get("features") + if features_filter: + if isinstance(features_filter, str): + features_filter = [features_filter] + features_lower = [f.lower() for f in features_filter] + servers = [ + s + for s in servers + if any(f in [sf.lower() for sf in s.get("features", [])] for f in features_lower) + ] + + fmt = args.get("format", "json") + + return { + "success": True, + "total": len(servers), + "servers": servers, + "format": fmt, + } + + +def proton_vpn_killswitch(args: dict) -> dict: + """Enable or disable the VPN kill switch.""" + priv = _privilege_guard() + if priv: + return priv + + state = args.get("state", "").lower() + if state not in ("on", "off"): + return { + "success": False, + "error": "state must be 'on' or 'off'", + } + + result = _run_vpn(["settings", f"--killswitch={state}"]) + + if not result["success"]: + return result + + return { + "success": True, + "killswitch": state, + "message": f"Kill switch {state == 'on' and 'enabled' or 'disabled'}.", + "output": result["output"], + } + + +def proton_vpn_config(args: dict) -> dict: + """View or modify VPN configuration.""" + priv = _privilege_guard() + if priv: + return priv + + action = args.get("action", "view") + + if action == "view": + result = _run_vpn(["config", "--list"]) + if not result["success"]: + return result + parsed = _parse_config(result["output"]) + return { + "success": True, + "action": "view", + "config": parsed, + } + + value = args.get("value", "") + + if action == "set-dns": + result = _run_vpn(["settings", f"--custom-dns={value}"]) + elif action == "set-netshield": + if value not in ("on", "off", "strict"): + return {"success": False, "error": "NetShield value must be 'on', 'off', or 'strict'"} + result = _run_vpn(["settings", f"--netshield={value}"]) + elif action == "set-protocol": + if value not in ("wireguard", "openvpn_udp", "openvpn_tcp"): + return {"success": False, "error": "Protocol must be 'wireguard', 'openvpn_udp', or 'openvpn_tcp'"} + result = _run_vpn(["settings", f"--protocol={value}"]) + else: + return {"success": False, "error": f"Unknown action: {action}"} + + if not result["success"]: + return result + + return { + "success": True, + "action": action, + "value": value, + "message": f"Configuration updated: {action} = {value}", + } + + +def proton_vpn_refresh(args: dict) -> dict: + """Refresh server list and VPN config from Proton's API.""" + priv = _privilege_guard() + if priv: + return priv + + result = _run_vpn(["refresh"], timeout=60) + + if not result["success"]: + return result + + return { + "success": True, + "message": "Server list and configuration refreshed.", + "output": result["output"], + } + + +# ── tool registry ────────────────────────────────────────────────────── + + +TOOLS: dict[str, dict[str, Any]] = { + "proton_vpn_login": { + "handler": proton_vpn_login, + "schema": { + "name": "proton_vpn_login", + "description": "Authenticate with Proton VPN. Requires a desktop session for browser OAuth.", + "parameters": { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "Proton account username/email (optional)." + } + } + } + } + }, + "proton_vpn_logout": { + "handler": proton_vpn_logout, + "schema": { + "name": "proton_vpn_logout", + "description": "Log out from Proton VPN. Clears stored credentials and disconnects.", + "parameters": {"type": "object", "properties": {}} + } + }, + "proton_vpn_connect": { + "handler": proton_vpn_connect, + "schema": { + "name": "proton_vpn_connect", + "description": "Connect to a Proton VPN server. Supports fastest, random, country, city, P2P, Tor, Secure Core, and free server selection.", + "parameters": { + "type": "object", + "properties": { + "server": { + "type": "string", + "description": "Server name or ID (e.g., 'US-NY#1')." + }, + "selection": { + "type": "string", + "enum": ["fastest", "random", "country", "city", "p2p", "tor", "free", "secure-core"], + "description": "Server selection strategy. Default: fastest." + }, + "country": { + "type": "string", + "description": "Two-letter country code (e.g., 'US'). Used with selection='country'." + }, + "city": { + "type": "string", + "description": "City name (e.g., 'New York'). Used with selection='city'." + }, + "protocol": { + "type": "string", + "enum": ["wireguard", "openvpn_udp", "openvpn_tcp"], + "description": "VPN protocol." + }, + "persistent": { + "type": "boolean", + "description": "Auto-reconnect on disconnect." + } + } + } + } + }, + "proton_vpn_disconnect": { + "handler": proton_vpn_disconnect, + "schema": { + "name": "proton_vpn_disconnect", + "description": "Disconnect the current VPN session and restore normal network connectivity.", + "parameters": {"type": "object", "properties": {}} + } + }, + "proton_vpn_status": { + "handler": proton_vpn_status, + "schema": { + "name": "proton_vpn_status", + "description": "Check current VPN connection status — server, protocol, uptime, IP information.", + "parameters": {"type": "object", "properties": {}} + } + }, + "proton_vpn_servers": { + "handler": proton_vpn_servers, + "schema": { + "name": "proton_vpn_servers", + "description": "List available Proton VPN servers with country, city, load, and features.", + "parameters": { + "type": "object", + "properties": { + "country": { + "type": "string", + "description": "Filter by two-letter country code (e.g., 'US')." + }, + "features": { + "type": "array", + "items": { + "type": "string", + "enum": ["p2p", "tor", "secure-core", "free"] + }, + "description": "Filter by required features." + }, + "format": { + "type": "string", + "enum": ["table", "json"], + "description": "Output format. 'json' (default) returns structured data." + } + } + } + } + }, + "proton_vpn_killswitch": { + "handler": proton_vpn_killswitch, + "schema": { + "name": "proton_vpn_killswitch", + "description": "Enable or disable the VPN kill switch. When enabled, all internet traffic is blocked outside the VPN tunnel.", + "parameters": { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": ["on", "off"], + "description": "Kill switch state." + } + }, + "required": ["state"] + } + } + }, + "proton_vpn_config": { + "handler": proton_vpn_config, + "schema": { + "name": "proton_vpn_config", + "description": "View current Proton VPN configuration or modify DNS, NetShield, and protocol settings.", + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["view", "set-dns", "set-netshield", "set-protocol"], + "description": "'view' (default): show config. 'set-*': modify setting." + }, + "value": { + "type": "string", + "description": "Setting value. For DNS: comma-separated IPs. For NetShield: 'on'/'off'/'strict'. For protocol: 'wireguard'/'openvpn_udp'/'openvpn_tcp'." + } + } + } + } + }, + "proton_vpn_refresh": { + "handler": proton_vpn_refresh, + "schema": { + "name": "proton_vpn_refresh", + "description": "Refresh server list and VPN configuration from Proton's API.", + "parameters": {"type": "object", "properties": {}} + } + }, +} + + +def dispatch(tool_name: str, args: dict | None = None) -> str: + """ + Dispatch a tool by name with the given arguments. + + Returns a JSON string (Hermes tools return strings, not dicts). + """ + if args is None: + args = {} + + tool = TOOLS.get(tool_name) + if tool is None: + return json.dumps({ + "success": False, + "error": f"Unknown tool: {tool_name}. Available: {', '.join(sorted(TOOLS.keys()))}", + }) + + try: + result = tool["handler"](args) + return json.dumps(result, indent=2, default=str) + except Exception as exc: + return json.dumps({ + "success": False, + "error": f"{tool_name} raised: {exc}", + }, indent=2) + + +# ── CLI entry point (for testing) ────────────────────────────────────── + + +def main(): + """CLI entry point for testing tools from the command line.""" + import sys + + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} [json_args]") + print(f"Tools: {', '.join(sorted(TOOLS.keys()))}") + sys.exit(1) + + tool_name = sys.argv[1] + args = {} + if len(sys.argv) > 2: + try: + args = json.loads(sys.argv[2]) + except json.JSONDecodeError as exc: + print(json.dumps({"error": f"Invalid JSON args: {exc}"})) + sys.exit(1) + + result = dispatch(tool_name, args) + print(result) + + +if __name__ == "__main__": + main() diff --git a/skills/proton-vpn/scripts/verify.py b/skills/proton-vpn/scripts/verify.py new file mode 100644 index 0000000..a92a772 --- /dev/null +++ b/skills/proton-vpn/scripts/verify.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Verify proton-vpn skill module loads and all tools are registered.""" +import sys +sys.path.insert(0, 'skills/proton-vpn/scripts') + +import tools +import json + +# Verify all tools are in registry +print("=== Tool Registry ===") +for name, info in sorted(tools.TOOLS.items()): + params = info["schema"].get("parameters", {}).get("properties", {}) + print(f" ✓ {name} — {len(params)} params") + +print(f"\nTotal tools: {len(tools.TOOLS)}") + +# Test dispatch for error cases (no CLI installed = expected) +result = json.loads(tools.dispatch('proton_vpn_status')) +print(f"\nStatus dispatch (expected error — no CLI): success={result.get('success')}") +print(f" error={result.get('error', '')[:120]}") + +result2 = json.loads(tools.dispatch('nonexistent')) +print(f"\nNonexistent tool: error={result2.get('error', '')[:80]}")