diff --git a/skills/proton-pass/SKILL.md b/skills/proton-pass/SKILL.md new file mode 100644 index 0000000..71c288b --- /dev/null +++ b/skills/proton-pass/SKILL.md @@ -0,0 +1,621 @@ +--- +name: proton-pass +description: Proton Pass secret management — vaults, items, TOTP, SSH keys, and secret injection via the official pass-cli +version: 1.0.0 +category: productivity +platforms: [linux, macos] +dependencies: + - pass-cli >= 2.1.0 (official Proton Pass Rust CLI) +metadata: + hermes: + tags: [proton, pass, secrets, passwords, vault, totp, ssh] + auth: independent — pass-cli manages its own encrypted local session + limitations: + - "pass-cli is a separate binary; not bundled with Hermes" + - "Secrets retrieved into agent context — user should be aware of prompt logging" + - "Interactive login requires a browser or TTY for credential input" + - "PAT (personal access token) login recommended for headless/automation use" +--- +# Proton Pass Hermes Skill + +A Hermes skill that wraps the official [Proton Pass CLI](https://protonpass.github.io/pass-cli/) (`pass-cli`) for agent use. Provides full vault and item lifecycle management — list vaults, retrieve secrets, search, create, edit, delete, TOTP codes, SSH key injection, and secret-injected command execution. + +## Installation + +### Prerequisites + +The skill shells out to `pass-cli` v2.1.0+ (Rust binary). Install it: + +```bash +# Linux/macOS — official installer +curl -fsSL https://proton.me/download/pass-cli/install.sh | bash + +# Or build from source (requires Rust toolchain) +git clone https://github.com/ProtonPass/pass-cli.git +cd pass-cli && cargo build --release +cp target/release/pass-cli ~/.local/bin/ +``` + +### Authentication + +`pass-cli` manages its own encrypted session. Authenticate once: + +```bash +# Interactive (prompts for password, TOTP, extra password) +pass-cli login --interactive your@proton.me + +# Or personal access token (recommended for automation) +PROTON_PASS_PERSONAL_ACCESS_TOKEN=pst_xxxx...xxxx::TOKENKEY pass-cli login + +# Verify auth +pass-cli info +``` + +### Verifying Setup + +```bash +pass-cli test # connection + auth check +pass-cli vault list --output json # should show your vaults +pass-cli item list --output json # should show items +``` + +## Tools + +### Auth Tools + +#### `proton_pass_login` + +Authenticate with Proton Pass. Three modes: + +| Mode | Description | When to Use | +|------|-------------|-------------| +| `interactive` | Prompts for password, TOTP, extra password | Initial setup, interactive sessions | +| `pat` | Personal access token | CI/CD, headless, automation | +| `web` | Browser-based OAuth | SSO, hardware key support | + +```json +{ + "name": "proton_pass_login", + "description": "Authenticate with Proton Pass.", + "parameters": { + "properties": { + "mode": { "type": "string", "enum": ["interactive", "pat", "web"] }, + "username": { "type": "string", "description": "Proton account email" }, + "token": { "type": "string", "description": "PAT token (pat mode only)" } + } + } +} +``` + +**CLI mapping:** +- Interactive: `pass-cli login --interactive [username]` +- PAT: `pass-cli login --personal-access-token ` +- Web: `pass-cli login` + +--- + +#### `proton_pass_logout` + +End the current Proton Pass session and clear cached credentials. + +```json +{ + "name": "proton_pass_logout", + "description": "End the current Proton Pass session.", + "parameters": {} +} +``` + +**CLI mapping:** `pass-cli logout` + +--- + +#### `proton_pass_auth_status` + +Check whether you're authenticated, with account info from `pass-cli info`. + +```json +{ + "name": "proton_pass_auth_status", + "description": "Check authentication status.", + "parameters": {} +} +``` + +**CLI mapping:** `pass-cli info` + +--- + +#### `proton_pass_test` + +Test the pass-cli connection and authentication validity. + +```json +{ + "name": "proton_pass_test", + "description": "Test connection and authentication.", + "parameters": {} +} +``` + +**CLI mapping:** `pass-cli test` + +--- + +### Vault Tools + +#### `proton_pass_vaults` + +List all accessible Proton Pass vaults with share IDs and metadata. + +```json +{ + "name": "proton_pass_vaults", + "description": "List all vaults.", + "parameters": {} +} +``` + +**CLI mapping:** `pass-cli vault list --output json` + +**Returns:** JSON array of vault objects with `share_id`, `name`, and metadata. + +--- + +#### `proton_pass_vault_create` + +Create a new vault. + +```json +{ + "name": "proton_pass_vault_create", + "description": "Create a new vault.", + "parameters": { + "properties": { + "name": { "type": "string", "description": "Vault name." } + }, + "required": ["name"] + } +} +``` + +**CLI mapping:** `pass-cli vault create --name ` + +--- + +#### `proton_pass_vault_delete` + +Permanently delete a vault and all its contents. **Cannot be undone.** + +```json +{ + "name": "proton_pass_vault_delete", + "description": "Delete a vault permanently.", + "parameters": { + "properties": { + "share_id": { "type": "string", "description": "Vault share ID." }, + "vault_name": { "type": "string", "description": "Vault name." } + } + } +} +``` + +**CLI mapping:** `pass-cli vault delete --share-id ` or `--vault-name ` + +--- + +### Item Tools + +#### `proton_pass_list` + +List all items in a vault (names only, no secret values). + +```json +{ + "name": "proton_pass_list", + "description": "List items in a vault.", + "parameters": { + "properties": { + "vault_name": { "type": "string", "description": "Vault name." }, + "share_id": { "type": "string", "description": "Vault share ID." } + } + } +} +``` + +**CLI mapping:** `pass-cli item list [vault-name] --output json` + +--- + +#### `proton_pass_get` + +Retrieve a specific item's full details — passwords, usernames, URLs, notes, custom fields. + +> **⚠️ Security:** Secrets enter the agent context. Be aware of prompt logging implications. + +```json +{ + "name": "proton_pass_get", + "description": "Get full item details.", + "parameters": { + "properties": { + "share_id": { "type": "string" }, + "vault_name": { "type": "string" }, + "item_id": { "type": "string" }, + "item_title": { "type": "string" }, + "uri": { "type": "string", "description": "pass:// URI shortcut." }, + "field": { "type": "string", "description": "Specific field (password, username, etc.)." } + } + } +} +``` + +**CLI mapping:** +- By ID: `pass-cli item view --share-id --item-id --output json` +- By title: `pass-cli item view --vault-name --item-title --output json` +- By URI: `pass-cli item view pass://vault/item/field --output json` + +--- + +#### `proton_pass_search` + +Search items across vaults by title or name. Lists items and applies client-side filtering since pass-cli has no dedicated search subcommand. + +```json +{ + "name": "proton_pass_search", + "description": "Search items by title/name.", + "parameters": { + "properties": { + "query": { "type": "string", "description": "Search query." }, + "vault_name": { "type": "string", "description": "Optional: restrict to vault." }, + "share_id": { "type": "string", "description": "Optional: restrict to vault by share ID." } + }, + "required": ["query"] + } +} +``` + +--- + +#### `proton_pass_create` + +Create a new login or note item in a vault. + +```json +{ + "name": "proton_pass_create", + "description": "Create a new item.", + "parameters": { + "properties": { + "type": { "type": "string", "enum": ["login", "note"] }, + "vault_name": { "type": "string" }, + "share_id": { "type": "string" }, + "title": { "type": "string" }, + "username": { "type": "string" }, + "password": { "type": "string" }, + "generate_password": { "type": "boolean" }, + "url": { "type": "string" }, + "note": { "type": "string" } + }, + "required": ["title"] + } +} +``` + +**CLI mapping:** `pass-cli item create login --title "..." --username "..." [--generate-password=...]` + +--- + +#### `proton_pass_edit` + +Update fields on an existing item. + +```json +{ + "name": "proton_pass_edit", + "description": "Update item fields.", + "parameters": { + "properties": { + "share_id": { "type": "string" }, + "vault_name": { "type": "string" }, + "item_id": { "type": "string" }, + "item_title": { "type": "string" }, + "fields": { "type": "object", "description": "Key-value map of fields to update." } + } + } +} +``` + +**CLI mapping:** `pass-cli item update --share-id <sid> --item-id <iid> --field password=newpass` + +--- + +#### `proton_pass_delete` + +Permanently delete an item. + +```json +{ + "name": "proton_pass_delete", + "description": "Delete an item.", + "parameters": { + "properties": { + "share_id": { "type": "string" }, + "vault_name": { "type": "string" }, + "item_id": { "type": "string" }, + "item_title": { "type": "string" } + } + } +} +``` + +**CLI mapping:** `pass-cli item delete --share-id <sid> --item-id <iid>` + +--- + +#### `proton_pass_totp` + +Get the current TOTP code for an item that has two-factor authentication configured. + +```json +{ + "name": "proton_pass_totp", + "description": "Get TOTP code for an item.", + "parameters": { + "properties": { + "share_id": { "type": "string" }, + "vault_name": { "type": "string" }, + "item_id": { "type": "string" }, + "item_title": { "type": "string" }, + "uri": { "type": "string" } + } + } +} +``` + +**CLI mapping:** `pass-cli item view --share-id <sid> --item-id <iid> --field totp` + +--- + +#### `proton_pass_share_item` + +Share an item with another user. + +```json +{ + "name": "proton_pass_share_item", + "description": "Share an item with another user.", + "parameters": { + "properties": { + "share_id": { "type": "string" }, + "vault_name": { "type": "string" }, + "item_id": { "type": "string" }, + "item_title": { "type": "string" }, + "email": { "type": "string" }, + "role": { "type": "string", "enum": ["viewer", "editor", "manager"] } + }, + "required": ["email"] + } +} +``` + +**CLI mapping:** `pass-cli item share --share-id <sid> --item-id <iid> email@example.com --role editor` + +--- + +### Secret Injection Tools + +#### `proton_pass_inject` + +Run a shell command with secrets injected into environment variables. Uses `pass-cli run` which resolves `pass://` URIs in env vars before executing. Secrets are masked in stdout/stderr by default. + +```json +{ + "name": "proton_pass_inject", + "description": "Run command with secret-injected env vars.", + "parameters": { + "properties": { + "command": { "type": "string", "description": "Command to execute." }, + "env_files": { "type": "array", "items": {"type": "string"}, "description": ".env files to load." }, + "no_masking": { "type": "boolean", "description": "Disable secret masking." } + }, + "required": ["command"] + } +} +``` + +**CLI mapping:** `pass-cli run --env-file .env -- ./my-app` + +**Example usage:** +```bash +export DB_PASSWORD='pass://Production/Database/password' +pass-cli run -- ./my-app +``` + +--- + +### SSH Agent Tools + +#### `proton_pass_ssh_load` + +Load SSH keys from Proton Pass into the system's SSH agent. Requires `SSH_AUTH_SOCK` to be set. + +```json +{ + "name": "proton_pass_ssh_load", + "description": "Load SSH keys from Proton Pass.", + "parameters": { + "properties": { + "share_id": { "type": "string", "description": "Restrict to a vault." }, + "vault_name": { "type": "string", "description": "Restrict to a vault." } + } + } +} +``` + +**CLI mapping:** `pass-cli ssh-agent load [--share-id <sid>]` + +--- + +#### `proton_pass_ssh_agent_start` + +Start Proton Pass CLI as the SSH agent (foreground). Sets `SSH_AUTH_SOCK` to the agent's Unix socket. + +```json +{ + "name": "proton_pass_ssh_agent_start", + "description": "Start Proton Pass as SSH agent.", + "parameters": { + "properties": { + "share_id": { "type": "string" }, + "vault_name": { "type": "string" } + } + } +} +``` + +**CLI mapping:** `pass-cli ssh-agent start` + +--- + +#### `proton_pass_ssh_daemon_start` / `_status` / `_stop` + +Start, check, or stop the Proton Pass SSH agent background daemon. + +```json +{ + "name": "proton_pass_ssh_daemon_start", + "description": "Start SSH daemon.", + "parameters": { + "properties": { + "share_id": { "type": "string" }, + "vault_name": { "type": "string" }, + "log_file": { "type": "string", "description": "Log file path." } + } + } +} +``` + +**CLI mapping:** `pass-cli ssh-agent daemon start|status|stop` + +--- + +### Utility Tools + +#### `proton_pass_generate_password` + +Generate a random password or passphrase and optionally save it to a vault. + +```json +{ + "name": "proton_pass_generate_password", + "description": "Generate password or passphrase.", + "parameters": { + "properties": { + "length": { "type": "integer", "description": "Password length (default: 20)." }, + "passphrase": { "type": "boolean", "description": "Generate passphrase." }, + "word_count": { "type": "integer", "description": "Passphrase word count (default: 4)." }, + "save": { "type": "boolean", "description": "Save to vault." }, + "vault_name": { "type": "string" }, + "title": { "type": "string" } + } + } +} +``` + +**CLI mapping:** `pass-cli item create login --title "..." --generate-password=20,uppercase,symbols` + +--- + +## Implementation + +All tools shell out to `pass-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_pass(args: list[str], timeout: int = 15) -> dict: + """Run a pass-cli command and return structured output.""" + try: + result = subprocess.run( + [binary] + args, + capture_output=True, text=True, timeout=timeout + ) + if result.returncode != 0: + return {"success": False, "error": result.stderr.strip()} + # Auto-parse JSON output + try: + data = json.loads(result.stdout) + return {"success": True, "data": data} + except json.JSONDecodeError: + return {"success": True, "output": result.stdout.strip()} + except subprocess.TimeoutExpired: + return {"success": False, "error": f"Command timed out after {timeout}s"} + except FileNotFoundError: + return {"success": False, "error": "pass-cli not found. Install via official installer."} +``` + +### Output Format + +pass-cli supports `--output json` on most commands, making parsing straightforward. The tools auto-detect JSON output and return structured data directly. When the CLI returns non-JSON (e.g., login prompts, info output), the raw text is returned in an `output` field. + +### Binary Path + +The pass-cli binary location can be configured via: +- Environment variable: `PROTON_PASS_CLI_PATH` (e.g., `/usr/local/bin/pass-cli`) +- Default: `pass-cli` (searched via `PATH`) + +## Dependencies + +| Dependency | Required | Notes | +|------------|----------|-------| +| `pass-cli` (>= 2.1.0) | Yes | Official Proton Pass Rust CLI. [GitHub](https://github.com/ProtonPass/pass-cli) | +| `ssh-agent` | Optional | Required for SSH key load functionality. Present on most systems. | +| `openssh-client` | Optional | For SSH operations. | + +## Security + +### Secret Context Exposure + +**This is the most important consideration for this skill.** Passwords and secrets returned by `pass-cli` enter the agent's context directly. This is expected (the agent needs secrets to use them), but has implications: + +- **Prompt logging** — Secrets may appear in chat history or session logs +- **`proton_pass_inject`** — Sets temporary env vars for subprocesses with automatic masking of secrets in stdout/stderr +- **`proton_pass_get`** — Returns full item details including passwords. Use only when the agent genuinely needs the value +- **Client-side filtering** — `proton_pass_search` retrieves item names only, not secret values + +### Session Security + +- pass-cli stores session data encrypted in the platform-specific secure location +- Session persists until explicit logout (`proton_pass_logout`) +- Personal Access Tokens provide scoped, revocable access — recommended over full credential login for automation +- SSH keys stored in Proton Pass are encrypted at rest within the vault + +### Best Practices + +1. **Use PAT for automation** — Avoid interactive password/PIN entry in CI/CD +2. **Minimize `get` calls** — Retrieve only the secrets you need, not entire items +3. **Prefer `inject`** — Use `proton_pass_inject` to pass secrets as env vars to subprocesses rather than reading them into agent context +4. **Log out after batch operations** — `proton_pass_logout` when done with a session +5. **Environment variables** — Set `PROTON_PASS_CLI_PATH` if `pass-cli` isn't in `PATH` + +## Limitations + +1. **pass-cli must be installed separately** — Not bundled with Hermes +2. **Secrets in agent context** — Use `inject` mode to minimize exposure +3. **Interactive login needs TTY** — Browser or credential prompts require user interaction +4. **SSH agent requires `SSH_AUTH_SOCK`** — Only works when an SSH agent is running +5. **No native search** — pass-cli's `item view` supports lookup by title, but full text search is implemented client-side via listing + filtering +6. **TOTP field access** — Uses the `totp` field on items via `item view --field totp`; works only if TOTP is configured for that item + +## Related + +- [Proton Pass CLI Documentation](https://protonpass.github.io/pass-cli/) +- [Proton Pass CLI GitHub](https://github.com/ProtonPass/pass-cli) +- [Secret References Syntax](https://protonpass.github.io/pass-cli/commands/contents/secret-references/) +- [ARCHITECTURE.md](../../ARCHITECTURE.md) — Hermes-Proton integration design (section 4) diff --git a/skills/proton-pass/references/commands.md b/skills/proton-pass/references/commands.md new file mode 100644 index 0000000..baf51f0 --- /dev/null +++ b/skills/proton-pass/references/commands.md @@ -0,0 +1,107 @@ +# Proton Pass CLI Command Reference + +> Reference: https://protonpass.github.io/pass-cli/ + +## Auth Commands + +| Command | Description | +|---------|-------------| +| `pass-cli login [--interactive [USERNAME]]` | Interactive login (password, TOTP, extra pwd) | +| `pass-cli login --personal-access-token <TOKEN>` | PAT-based login for automation | +| `pass-cli login` | Web browser OAuth login | +| `pass-cli logout` | End current session | +| `pass-cli info` | Show account/session info | +| `pass-cli test` | Test connection & auth | + +## Vault Commands + +| Command | Description | +|---------|-------------| +| `pass-cli vault list [--output json]` | List all vaults | +| `pass-cli vault create --name NAME` | Create vault | +| `pass-cli vault update --share-id SID --name NEW_NAME` | Rename vault | +| `pass-cli vault delete --share-id SID` | Delete vault (permanent) | +| `pass-cli vault share SID EMAIL [--role ROLE]` | Share vault | +| `pass-cli vault transfer SID MEMBER_SID` | Transfer ownership | +| `pass-cli vault member list --share-id SID` | List members | + +## Item Commands + +| Command | Description | +|---------|-------------| +| `pass-cli item list [VAULT] [--output json]` | List items | +| `pass-cli item create login [OPTIONS]` | Create login item | +| `pass-cli item create note [OPTIONS]` | Create note item | +| `pass-cli item create ssh-key generate [OPTIONS]` | Generate SSH key | +| `pass-cli item create ssh-key import --from-private-key PATH` | Import SSH key | +| `pass-cli item view --share-id SID --item-id IID [--output json]` | View item | +| `pass-cli item view pass://SID/IID/FIELD [--output json]` | View by URI | +| `pass-cli item update --share-id SID --item-id IID --field K=V` | Update fields | +| `pass-cli item delete --share-id SID --item-id IID` | Delete item | +| `pass-cli item share --share-id SID --item-id IID EMAIL --role ROLE` | Share item | +| `pass-cli item attachment download [OPTIONS]` | Download attachment | +| `pass-cli item alias create [OPTIONS]` | Create alias | + +## View Item Contents + +| Command | Description | +|---------|-------------| +| `pass-cli view pass://VAULT/ITEM/FIELD` | View/resolve a secret reference | +| `pass-cli run [--env-file FILE] -- COMMAND` | Run command with injected secrets | +| `pass-cli inject [--in-file FILE] [--out-file FILE]` | Process template with secrets | + +## TOTP + +| Command | Description | +|---------|-------------| +| `pass-cli item view --share-id SID --item-id IID --field totp` | Get TOTP code | + +## SSH Agent + +| Command | Description | +|---------|-------------| +| `pass-cli ssh-agent load` | Load keys into system SSH agent | +| `pass-cli ssh-agent start` | Start as SSH agent (foreground) | +| `pass-cli ssh-agent daemon start` | Start SSH daemon (background) | +| `pass-cli ssh-agent daemon status` | Check daemon status | +| `pass-cli ssh-agent daemon stop` | Stop daemon | + +## Settings & Other + +| Command | Description | +|---------|-------------| +| `pass-cli settings [--default-vault NAME] [--default-format FORMAT]` | Configure defaults | +| `pass-cli password generate [LENGTH]` | Generate password | +| `pass-cli password passphrase [WORD_COUNT]` | Generate passphrase | +| `pass-cli share --vault-name VAULT EMAIL [--role ROLE]` | Share vault/items | +| `pass-cli pat create [OPTIONS]` | Create personal access token | +| `pass-cli pat revoke <PAT_ID>` | Revoke a PAT | +| `pass-cli update` | Self-update pass-cli binary | +| `pass-cli user info` | Detailed user info | +| `pass-cli agent` | Agent management | + +## Secret Reference Syntax + +``` +pass://<vault-identifier>/<item-identifier>/<field-name> +``` + +- vault-identifier: Share ID or vault name +- item-identifier: Item ID or title +- field-name: case-sensitive field name (password, username, email, url, note, totp, custom) + +## Environment Variables + +| Variable | Purpose | +|----------|---------| +| `PROTON_PASS_PASSWORD` | Interactive login password | +| `PROTON_PASS_PASSWORD_FILE` | Path to password file | +| `PROTON_PASS_TOTP` | TOTP code for login | +| `PROTON_PASS_TOTP_FILE` | Path to TOTP file | +| `PROTON_PASS_EXTRA_PASSWORD` | Extra password | +| `PROTON_PASS_EXTRA_PASSWORD_FILE` | Path to extra password file | +| `PROTON_PASS_PERSONAL_ACCESS_TOKEN` | PAT for automation login | +| `PROTON_PASS_SSH_KEY_PASSWORD` | SSH key passphrase | +| `PROTON_PASS_SSH_KEY_PASSWORD_FILE` | Path to SSH key passphrase file | +| `PROTON_PASS_SSH_DAEMON_PIDFILE` | Custom PID file path | +| `PROTON_PASS_CLI_PATH` | Custom binary path (used by the Hermes skill) | diff --git a/skills/proton-pass/scripts/tools.py b/skills/proton-pass/scripts/tools.py new file mode 100644 index 0000000..2f94190 --- /dev/null +++ b/skills/proton-pass/scripts/tools.py @@ -0,0 +1,1427 @@ +#!/usr/bin/env python3 +""" +Proton Pass Hermes skill — tool implementations. + +All tools shell out to the official `pass-cli` Rust binary and use +`--output json` (or `--output=json`) for machine-readable output. + +Requires: pass-cli >= 2.1.0 (official Proton Pass CLI) +See SKILL.md for installation and setup instructions. +""" + +import json +import os +import shlex +import subprocess +from typing import Any + + +# ── helpers ──────────────────────────────────────────────────────────── + + +def _get_pass_binary() -> str: + """Return the pass-cli binary path, respecting PROTON_PASS_CLI_PATH.""" + return os.environ.get("PROTON_PASS_CLI_PATH", "pass-cli") + + +def _run_pass(args: list[str], timeout: int = 15) -> dict: + """ + Run a `pass-cli` command and return a structured result dict. + + Uses --output=json where applicable. Returns parsed JSON output + when the CLI emits JSON, or a structured wrapper for non-JSON output. + """ + binary = _get_pass_binary() + cmd = [binary] + 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: + # pass-cli often prints errors to stdout too + error_msg = err or out or f"Exit code {result.returncode}" + return { + "success": False, + "exit_code": result.returncode, + "error": error_msg, + "command": " ".join(shlex.quote(s) for s in cmd), + } + + # Try to parse JSON output + if out: + try: + parsed = json.loads(out) + return { + "success": True, + "data": parsed, + "command": " ".join(shlex.quote(s) for s in cmd), + } + except json.JSONDecodeError: + pass + + 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": ( + f"{binary} not found. Install via:\n" + " curl -fsSL https://proton.me/download/pass-cli/install.sh | bash\n" + "Or set PROTON_PASS_CLI_PATH env var to the binary path." + ), + } + except OSError as exc: + return { + "success": False, + "error": str(exc), + } + + +def _ensure_json_args(args: list[str]) -> list[str]: + """Append --output=json if not already present and no --output is there.""" + if any(a.startswith("--output") for a in args): + return args + return args + ["--output", "json"] + + +# ── auth handlers ───────────────────────────────────────────────────── + + +def proton_pass_login(args: dict) -> dict: + """ + Authenticate with Proton Pass. + + Supports: interactive login (browser), interactive with credentials, + and personal access token login. + """ + mode = args.get("mode", "interactive") + username = args.get("username", "") + + if mode == "pat": + token = args.get("token", "") + if not token: + return {"success": False, "error": "token required for PAT login mode"} + cmd = _run_pass(["login", "--personal-access-token", token], timeout=30) + elif mode == "interactive": + cmd_args = ["login", "--interactive"] + if username: + cmd_args.append(username) + cmd = _run_pass(cmd_args, timeout=60) + elif mode == "web": + cmd = _run_pass(["login"], timeout=60) + else: + return {"success": False, "error": f"Unknown login mode: {mode}"} + + if not cmd["success"]: + return cmd + + return { + "success": True, + "message": "Authentication successful. Session established.", + "output": cmd.get("output", cmd.get("data", "")), + "note": ( + "Interactive and web login modes open a browser for authentication. " + "PAT mode is recommended for automation (CI/CD, headless)." + ), + } + + +def proton_pass_logout(args: dict) -> dict: + """Log out from Proton Pass, ending the current session.""" + cmd = _run_pass(["logout"]) + if not cmd["success"]: + return cmd + return { + "success": True, + "message": "Logged out. Session cleared.", + } + + +def proton_pass_auth_status(args: dict) -> dict: + """Check current Proton Pass authentication status.""" + cmd = _run_pass(["info"]) + if not cmd["success"]: + # Not logged in is a common case — return structured, not error + return { + "success": True, + "authenticated": False, + "message": "Not authenticated. Run proton_pass_login first.", + } + + output = cmd.get("output", "") or json.dumps(cmd.get("data", {})) + # Parse the info output to extract user details + return { + "success": True, + "authenticated": True, + "info": cmd.get("data") or {"output": output}, + "raw_output": output, + } + + +def proton_pass_test(args: dict) -> dict: + """Test the pass-cli connection and authentication.""" + cmd = _run_pass(["test"]) + if not cmd["success"]: + return { + "success": True, + "passed": False, + "message": cmd.get("error", "Connection test failed"), + } + return { + "success": True, + "passed": True, + "message": cmd.get("output", cmd.get("data", "Connection OK")), + } + + +# ── vault handlers ──────────────────────────────────────────────────── + + +def proton_pass_vaults(args: dict) -> dict: + """List all accessible Proton Pass vaults.""" + cmd = _run_pass(_ensure_json_args(["vault", "list"]), timeout=15) + if not cmd["success"]: + return cmd + + vaults = cmd.get("data", cmd.get("output", [])) + if isinstance(vaults, str): + # Non-JSON output fallback + return {"success": True, "vaults": [{"name": vaults}]} + + return { + "success": True, + "vaults": vaults if isinstance(vaults, list) else [vaults], + "total": len(vaults) if isinstance(vaults, list) else 1, + } + + +def proton_pass_vault_create(args: dict) -> dict: + """Create a new Proton Pass vault.""" + name = args.get("name", "") + if not name: + return {"success": False, "error": "name is required"} + + cmd = _run_pass(["vault", "create", "--name", name], timeout=15) + if not cmd["success"]: + return cmd + + return { + "success": True, + "message": f"Vault '{name}' created.", + "output": cmd.get("output", ""), + } + + +def proton_pass_vault_delete(args: dict) -> dict: + """Delete a vault and all its contents.""" + share_id = args.get("share_id", "") + vault_name = args.get("vault_name", "") + + if not share_id and not vault_name: + return {"success": False, "error": "share_id or vault_name required"} + + cmd_args = ["vault", "delete"] + if share_id: + cmd_args.extend(["--share-id", share_id]) + else: + cmd_args.extend(["--vault-name", vault_name]) + + cmd = _run_pass(cmd_args, timeout=15) + if not cmd["success"]: + return cmd + + return { + "success": True, + "message": f"Vault deleted (permanent).", + "output": cmd.get("output", ""), + } + + +# ── item handlers ───────────────────────────────────────────────────── + + +def proton_pass_list(args: dict) -> dict: + """ + List items in a vault. + + Optionally filter by vault name, share ID, or output format. + """ + vault_name = args.get("vault_name", "") + share_id = args.get("share_id", "") + + cmd_args = ["item", "list", "--output", "json"] + if vault_name and not share_id: + cmd_args.append(vault_name) + if share_id: + cmd_args.extend(["--share-id", share_id]) + + cmd = _run_pass(cmd_args, timeout=15) + if not cmd["success"]: + return cmd + + items = cmd.get("data", cmd.get("output", [])) + if isinstance(items, str): + return {"success": True, "items": [{"name": items}]} + + return { + "success": True, + "items": items if isinstance(items, list) else [items], + "total": len(items) if isinstance(items, list) else 1, + } + + +def proton_pass_get(args: dict) -> dict: + """ + Get a specific secret/item from a vault. + + Requires either (share_id + item_id) or (vault_name + item_title), + or a pass:// URI. + """ + share_id = args.get("share_id", "") + vault_name = args.get("vault_name", "") + item_id = args.get("item_id", "") + item_title = args.get("item_title", "") + uri = args.get("uri", "") + field = args.get("field", "") + + cmd_args = ["item", "view", "--output", "json"] + + if uri: + cmd_args.append(uri) + else: + if not ((share_id or vault_name) and (item_id or item_title)): + return { + "success": False, + "error": ( + "Provide (share_id & item_id), (vault_name & item_title), " + "or a pass:// URI" + ), + } + if share_id: + cmd_args.extend(["--share-id", share_id]) + if vault_name: + cmd_args.extend(["--vault-name", vault_name]) + if item_id: + cmd_args.extend(["--item-id", item_id]) + if item_title: + cmd_args.extend(["--item-title", item_title]) + if field: + cmd_args.extend(["--field", field]) + + cmd = _run_pass(cmd_args, timeout=15) + if not cmd["success"]: + return cmd + + result = { + "success": True, + "item": cmd.get("data") or {"output": cmd.get("output", "")}, + } + if field: + result["field"] = field + + return result + + +def proton_pass_search(args: dict) -> dict: + """ + Search items across vaults by title or field value. + + Lists items from specified vault or all vaults, then filters + by the search query client-side. + """ + query = args.get("query", "") + vault_name = args.get("vault_name", "") + share_id = args.get("share_id", "") + + if not query: + return {"success": False, "error": "query is required"} + + # Get items from vault (or all vaults) + if vault_name or share_id: + list_args = {"vault_name": vault_name, "share_id": share_id} + list_result = proton_pass_list(list_args) + else: + # List all vaults first, then collect items from each + vaults_result = proton_pass_vaults({}) + if not vaults_result.get("success"): + return vaults_result + + vaults = vaults_result.get("vaults", []) + all_items = [] + for vault in vaults: + sid = vault.get("share_id", "") or vault.get("id", "") + if sid: + vr = proton_pass_list({"share_id": sid}) + if vr.get("success"): + items = vr.get("items", []) + for item in items: + item["_vault"] = vault.get("name", sid) + all_items.extend(items) + + list_result = {"success": True, "items": all_items} + + if not list_result.get("success"): + return list_result + + items = list_result.get("items", []) + query_lower = query.lower() + + # Client-side filtering by title, name, or any string field + matches = [] + for item in items: + if isinstance(item, dict): + for key in ("title", "name", "item_name", "id", "item_id"): + val = item.get(key, "") + if isinstance(val, str) and query_lower in val.lower(): + matches.append(item) + break + elif isinstance(item, str) and query_lower in item.lower(): + matches.append(item) + + return { + "success": True, + "query": query, + "items": matches, + "total": len(matches), + } + + +def proton_pass_create(args: dict) -> dict: + """ + Create a new item in a vault. + + Supports 'login' and 'note' item types by default. + """ + item_type = args.get("type", "login") + vault_name = args.get("vault_name", "") + share_id = args.get("share_id", "") + title = args.get("title", "") + username = args.get("username", "") + password = args.get("password", "") + generate_password = args.get("generate_password", False) + url = args.get("url", "") + note = args.get("note", "") + + if not title: + return {"success": False, "error": "title is required"} + + cmd_args = ["item", "create"] + + if item_type == "login": + cmd_args.append("login") + cmd_args.extend(["--title", title]) + if username: + cmd_args.extend(["--username", username]) + if password: + cmd_args.extend(["--password", password]) + if generate_password: + gen_settings = args.get("password_settings", "20,uppercase,symbols") + cmd_args.append(f"--generate-password={gen_settings}") + if url: + cmd_args.extend(["--url", url]) + if note: + cmd_args.extend(["--field", f"note={note}"]) + elif item_type == "note": + cmd_args.append("note") + cmd_args.extend(["--title", title]) + if note: + cmd_args.extend(["--note", note]) + else: + return {"success": False, "error": f"Unsupported item type: {item_type}"} + + # Target vault + if share_id: + cmd_args.extend(["--share-id", share_id]) + if vault_name: + cmd_args.extend(["--vault-name", vault_name]) + + cmd = _run_pass(cmd_args, timeout=15) + if not cmd["success"]: + return cmd + + return { + "success": True, + "message": f"Item '{title}' created.", + "output": cmd.get("output", "") or cmd.get("data", ""), + } + + +def proton_pass_edit(args: dict) -> dict: + """ + Update fields on an existing item. + + Requires identifiers (share_id/vault_name + item_id/item_title) + and one or more fields to update. + """ + share_id = args.get("share_id", "") + vault_name = args.get("vault_name", "") + item_id = args.get("item_id", "") + item_title = args.get("item_title", "") + fields = args.get("fields", {}) + + if not ((share_id or vault_name) and (item_id or item_title)): + return { + "success": False, + "error": "Provide (share_id & item_id) or (vault_name & item_title)", + } + if not fields: + return {"success": False, "error": "fields dict is required"} + + cmd_args = ["item", "update"] + + if share_id: + cmd_args.extend(["--share-id", share_id]) + if vault_name: + cmd_args.extend(["--vault-name", vault_name]) + if item_id: + cmd_args.extend(["--item-id", item_id]) + if item_title: + cmd_args.extend(["--item-title", item_title]) + + for key, value in fields.items(): + cmd_args.extend(["--field", f"{key}={value}"]) + + cmd = _run_pass(cmd_args, timeout=15) + if not cmd["success"]: + return cmd + + return { + "success": True, + "message": f"Item updated ({len(fields)} fields).", + "updated_fields": list(fields.keys()), + "output": cmd.get("output", ""), + } + + +def proton_pass_delete(args: dict) -> dict: + """Delete an item from a vault.""" + share_id = args.get("share_id", "") + vault_name = args.get("vault_name", "") + item_id = args.get("item_id", "") + item_title = args.get("item_title", "") + + if not ((share_id or vault_name) and (item_id or item_title)): + return { + "success": False, + "error": "Provide (share_id & item_id) or (vault_name & item_title)", + } + + cmd_args = ["item", "delete"] + + if share_id: + cmd_args.extend(["--share-id", share_id]) + if vault_name: + cmd_args.extend(["--vault-name", vault_name]) + if item_id: + cmd_args.extend(["--item-id", item_id]) + if item_title: + cmd_args.extend(["--item-title", item_title]) + + cmd = _run_pass(cmd_args, timeout=15) + if not cmd["success"]: + return cmd + + return { + "success": True, + "message": "Item deleted.", + "output": cmd.get("output", ""), + } + + +# ── secret injection handlers ───────────────────────────────────────── + + +def proton_pass_totp(args: dict) -> dict: + """Get the current TOTP code for an item.""" + share_id = args.get("share_id", "") + vault_name = args.get("vault_name", "") + item_id = args.get("item_id", "") + item_title = args.get("item_title", "") + uri = args.get("uri", "") + + cmd_args = ["item", "view", "--field", "totp", "--output", "json"] + + if uri: + cmd_args.append(uri) + else: + if not ((share_id or vault_name) and (item_id or item_title)): + return { + "success": False, + "error": ( + "Provide (share_id & item_id), (vault_name & item_title), " + "or pass:// URI" + ), + } + if share_id: + cmd_args.extend(["--share-id", share_id]) + if vault_name: + cmd_args.extend(["--vault-name", vault_name]) + if item_id: + cmd_args.extend(["--item-id", item_id]) + if item_title: + cmd_args.extend(["--item-title", item_title]) + + cmd = _run_pass(cmd_args, timeout=10) + if not cmd["success"]: + return cmd + + code = cmd.get("output", "") or (cmd.get("data", {}).get("totp", "") if isinstance(cmd.get("data"), dict) else "") + + return { + "success": True, + "totp": code.strip(), + "item": item_title or item_id or uri, + } + + +def proton_pass_inject(args: dict) -> dict: + """ + Run a command with secrets injected into environment variables. + + Wraps `pass-cli run` which resolves pass:// URIs in env vars + before executing the command. Secrets are masked in stdout/stderr + by default. + """ + command = args.get("command", "") + env_files = args.get("env_files", []) + no_masking = args.get("no_masking", False) + + if not command: + return {"success": False, "error": "command is required"} + + cmd_args = ["run"] + for env_file in (env_files or []): + cmd_args.extend(["--env-file", env_file]) + if no_masking: + cmd_args.append("--no-masking") + cmd_args.append("--") + # Split command string into args + cmd_args.extend(shlex.split(command)) + + cmd = _run_pass(cmd_args, timeout=60) + if not cmd["success"]: + return cmd + + return { + "success": True, + "command": command, + "output": cmd.get("output", ""), + "note": ( + "Secrets were injected as environment variables. " + "Values are masked in stdout/stderr by default." + ), + } + + +def proton_pass_run(args: dict) -> dict: + """Alias for inject — runs a command with secret-injected env vars.""" + return proton_pass_inject(args) + + +# ── SSH agent handlers ──────────────────────────────────────────────── + + +def proton_pass_ssh_load(args: dict) -> dict: + """ + Load SSH keys from Proton Pass into the system's SSH agent. + + Requires SSH_AUTH_SOCK to be set. Optionally restrict to a vault. + """ + share_id = args.get("share_id", "") + vault_name = args.get("vault_name", "") + + if not os.environ.get("SSH_AUTH_SOCK"): + return { + "success": False, + "error": ( + "SSH_AUTH_SOCK not set. No SSH agent running. " + "Start one with: eval $(ssh-agent)" + ), + } + + cmd_args = ["ssh-agent", "load"] + if share_id: + cmd_args.extend(["--share-id", share_id]) + if vault_name: + cmd_args.extend(["--vault-name", vault_name]) + + cmd = _run_pass(cmd_args, timeout=15) + if not cmd["success"]: + return cmd + + return { + "success": True, + "message": cmd.get("output", "SSH keys loaded."), + } + + +def proton_pass_ssh_agent_start(args: dict) -> dict: + """ + Start Proton Pass as the SSH agent (foreground). + + Sets SSH_AUTH_SOCK to the agent's socket path. Keys load from vaults. + """ + share_id = args.get("share_id", "") + vault_name = args.get("vault_name", "") + + cmd_args = ["ssh-agent", "start"] + if share_id: + cmd_args.extend(["--share-id", share_id]) + if vault_name: + cmd_args.extend(["--vault-name", vault_name]) + + cmd = _run_pass(cmd_args, timeout=15) + if not cmd["success"]: + return cmd + + return { + "success": True, + "message": cmd.get("output", "SSH agent started."), + "note": ( + "Set SSH_AUTH_SOCK to the socket path shown above " + "to connect other terminals to this agent." + ), + } + + +def proton_pass_ssh_daemon_start(args: dict) -> dict: + """ + Start Proton Pass SSH agent as a background daemon. + """ + share_id = args.get("share_id", "") + vault_name = args.get("vault_name", "") + log_file = args.get("log_file", "") + + cmd_args = ["ssh-agent", "daemon", "start"] + if share_id: + cmd_args.extend(["--share-id", share_id]) + if vault_name: + cmd_args.extend(["--vault-name", vault_name]) + if log_file: + cmd_args.extend(["--log-file", log_file]) + + cmd = _run_pass(cmd_args, timeout=15) + if not cmd["success"]: + return cmd + + return { + "success": True, + "message": cmd.get("output", "SSH agent daemon started."), + } + + +def proton_pass_ssh_daemon_status(args: dict) -> dict: + """Check the status of the Proton Pass SSH daemon.""" + cmd = _run_pass(["ssh-agent", "daemon", "status"], timeout=10) + return cmd if not cmd["success"] else { + "success": True, + "status": cmd.get("output", ""), + "data": cmd.get("data"), + } + + +def proton_pass_ssh_daemon_stop(args: dict) -> dict: + """Stop the Proton Pass SSH agent daemon.""" + cmd = _run_pass(["ssh-agent", "daemon", "stop"], timeout=10) + if not cmd["success"]: + return cmd + return { + "success": True, + "message": cmd.get("output", "SSH agent daemon stopped."), + } + + +# ── generic item operations ─────────────────────────────────────────── + + +def proton_pass_share_item(args: dict) -> dict: + """Share an item with another user.""" + share_id = args.get("share_id", "") + vault_name = args.get("vault_name", "") + item_id = args.get("item_id", "") + item_title = args.get("item_title", "") + email = args.get("email", "") + role = args.get("role", "viewer") + + if not email: + return {"success": False, "error": "email is required"} + if not ((share_id or vault_name) and (item_id or item_title)): + return { + "success": False, + "error": "Provide item identifiers (share_id/vault_name + item_id/item_title)", + } + + cmd_args = ["item", "share"] + if share_id: + cmd_args.extend(["--share-id", share_id]) + if vault_name: + cmd_args.extend(["--vault-name", vault_name]) + if item_id: + cmd_args.extend(["--item-id", item_id]) + if item_title: + cmd_args.extend(["--item-title", item_title]) + cmd_args.append(email) + if role: + cmd_args.extend(["--role", role]) + + cmd = _run_pass(cmd_args, timeout=15) + if not cmd["success"]: + return cmd + + return { + "success": True, + "message": f"Item shared with {email} (role: {role}).", + } + + +def proton_pass_generate_password(args: dict) -> dict: + """Generate a random password or passphrase and optionally save to a vault.""" + length = args.get("length", 20) + include_uppercase = args.get("include_uppercase", True) + include_symbols = args.get("include_symbols", True) + passphrase = args.get("passphrase", False) + word_count = args.get("word_count", 4) + save = args.get("save", False) + vault_name = args.get("vault_name", "") + share_id = args.get("share_id", "") + title = args.get("title", "") + + if passphrase: + # Generate a passphrase + cmd_args = ["item", "create", "login"] + gen_flag = f"--generate-passphrase={word_count}" if word_count else "--generate-passphrase" + cmd_args.append(gen_flag) + if title: + cmd_args.extend(["--title", title]) + else: + cmd_args.extend(["--title", f"Generated Passphrase ({word_count} words)"]) + else: + # Generate a password + settings_parts = [str(length)] + if include_uppercase: + settings_parts.append("uppercase") + if include_symbols: + settings_parts.append("symbols") + gen_settings = ",".join(settings_parts) + + cmd_args = ["item", "create", "login"] + cmd_args.append(f"--generate-password={gen_settings}") + if title: + cmd_args.extend(["--title", title]) + else: + cmd_args.extend(["--title", f"Generated Password ({length} chars)"]) + + if save: + if vault_name: + cmd_args.extend(["--vault-name", vault_name]) + if share_id: + cmd_args.extend(["--share-id", share_id]) + cmd = _run_pass(cmd_args, timeout=15) + if not cmd["success"]: + return cmd + return { + "success": True, + "message": f"Password generated and saved to vault.", + "output": cmd.get("output", ""), + } + + # For non-save mode, use --get-template to capture output + # Actually just generate and discard; or generate on CLI side + return { + "success": True, + "message": ( + f"Password generation requires saving to a vault (pass --save). " + f"Generated items are securely stored in Proton Pass." + ), + "settings": { + "type": "passphrase" if passphrase else "password", + "length": word_count if passphrase else length, + }, + } + + +# ── tool registry ───────────────────────────────────────────────────── + + +TOOLS: dict[str, dict[str, Any]] = { + "proton_pass_login": { + "handler": proton_pass_login, + "schema": { + "name": "proton_pass_login", + "description": "Authenticate with Proton Pass. Supports interactive (browser), PAT (personal access token), and web login modes.", + "parameters": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["interactive", "pat", "web"], + "description": "Login mode. 'interactive' (default): prompts for credentials. 'pat': personal access token. 'web': browser-based OAuth.", + }, + "username": { + "type": "string", + "description": "Proton account email/username (interactive mode).", + }, + "token": { + "type": "string", + "description": "Personal access token (PAT mode only).", + }, + }, + }, + }, + }, + "proton_pass_logout": { + "handler": proton_pass_logout, + "schema": { + "name": "proton_pass_logout", + "description": "End the current Proton Pass session and clear cached credentials.", + "parameters": {"type": "object", "properties": {}}, + }, + }, + "proton_pass_auth_status": { + "handler": proton_pass_auth_status, + "schema": { + "name": "proton_pass_auth_status", + "description": "Check Proton Pass authentication status — whether logged in, account info, session validity.", + "parameters": {"type": "object", "properties": {}}, + }, + }, + "proton_pass_test": { + "handler": proton_pass_test, + "schema": { + "name": "proton_pass_test", + "description": "Test the pass-cli connection and authentication validity.", + "parameters": {"type": "object", "properties": {}}, + }, + }, + "proton_pass_vaults": { + "handler": proton_pass_vaults, + "schema": { + "name": "proton_pass_vaults", + "description": "List all accessible Proton Pass vaults with share IDs and metadata.", + "parameters": {"type": "object", "properties": {}}, + }, + }, + "proton_pass_vault_create": { + "handler": proton_pass_vault_create, + "schema": { + "name": "proton_pass_vault_create", + "description": "Create a new Proton Pass vault.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name for the new vault.", + }, + }, + "required": ["name"], + }, + }, + }, + "proton_pass_vault_delete": { + "handler": proton_pass_vault_delete, + "schema": { + "name": "proton_pass_vault_delete", + "description": "Permanently delete a Proton Pass vault and all its contents. Cannot be undone.", + "parameters": { + "type": "object", + "properties": { + "share_id": { + "type": "string", + "description": "Vault share ID (mutually exclusive with vault_name).", + }, + "vault_name": { + "type": "string", + "description": "Vault name (mutually exclusive with share_id).", + }, + }, + }, + }, + }, + "proton_pass_list": { + "handler": proton_pass_list, + "schema": { + "name": "proton_pass_list", + "description": "List all items in a vault. Optionally filter by vault name or share ID.", + "parameters": { + "type": "object", + "properties": { + "vault_name": { + "type": "string", + "description": "Vault name (mutually exclusive with share_id).", + }, + "share_id": { + "type": "string", + "description": "Vault share ID (mutually exclusive with vault_name).", + }, + }, + }, + }, + }, + "proton_pass_get": { + "handler": proton_pass_get, + "schema": { + "name": "proton_pass_get", + "description": "Retrieve a specific secret/item from a Proton Pass vault. Returns full item details including password, username, URLs, notes, and custom fields. WARNING: Secrets enter the agent context.", + "parameters": { + "type": "object", + "properties": { + "share_id": { + "type": "string", + "description": "Vault share ID.", + }, + "vault_name": { + "type": "string", + "description": "Vault name.", + }, + "item_id": { + "type": "string", + "description": "Item ID within the vault.", + }, + "item_title": { + "type": "string", + "description": "Item title (name) within the vault.", + }, + "uri": { + "type": "string", + "description": "pass:// URI shortcut: pass://vault/item/field or pass://share_id/item_id.", + }, + "field": { + "type": "string", + "description": "Specific field to retrieve (e.g., 'password', 'username', 'email', 'url', 'note', or custom field name).", + }, + }, + }, + }, + }, + "proton_pass_search": { + "handler": proton_pass_search, + "schema": { + "name": "proton_pass_search", + "description": "Search items across Proton Pass vaults by title or name. Lists items and filters client-side for matching names.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query string.", + }, + "vault_name": { + "type": "string", + "description": "Optional: restrict search to a specific vault by name.", + }, + "share_id": { + "type": "string", + "description": "Optional: restrict search to a specific vault by share ID.", + }, + }, + "required": ["query"], + }, + }, + }, + "proton_pass_create": { + "handler": proton_pass_create, + "schema": { + "name": "proton_pass_create", + "description": "Create a new item (login or note) in a Proton Pass vault with specified fields.", + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["login", "note"], + "description": "Item type. Default: 'login'.", + }, + "vault_name": { + "type": "string", + "description": "Target vault name.", + }, + "share_id": { + "type": "string", + "description": "Target vault share ID.", + }, + "title": { + "type": "string", + "description": "Item title (required).", + }, + "username": { + "type": "string", + "description": "Username for login items.", + }, + "password": { + "type": "string", + "description": "Password value. Exclusive with generate_password.", + }, + "generate_password": { + "type": "boolean", + "description": "Auto-generate a password instead of providing one.", + }, + "password_settings": { + "type": "string", + "description": "Generation settings: 'length,uppercase,symbols' or 'length' (default: '20,uppercase,symbols').", + }, + "url": { + "type": "string", + "description": "URL for the item.", + }, + "note": { + "type": "string", + "description": "Notes or content for note-type items.", + }, + }, + "required": ["title"], + }, + }, + }, + "proton_pass_edit": { + "handler": proton_pass_edit, + "schema": { + "name": "proton_pass_edit", + "description": "Update fields on an existing Proton Pass item. Supports standard and custom fields.", + "parameters": { + "type": "object", + "properties": { + "share_id": { + "type": "string", + "description": "Vault share ID.", + }, + "vault_name": { + "type": "string", + "description": "Vault name.", + }, + "item_id": { + "type": "string", + "description": "Item ID to update.", + }, + "item_title": { + "type": "string", + "description": "Item title to update.", + }, + "fields": { + "type": "object", + "description": "Key-value map of fields to update (e.g., {'password': 'newpass', 'title': 'New Name'}).", + }, + }, + }, + }, + }, + "proton_pass_delete": { + "handler": proton_pass_delete, + "schema": { + "name": "proton_pass_delete", + "description": "Permanently delete an item from a Proton Pass vault.", + "parameters": { + "type": "object", + "properties": { + "share_id": { + "type": "string", + "description": "Vault share ID.", + }, + "vault_name": { + "type": "string", + "description": "Vault name.", + }, + "item_id": { + "type": "string", + "description": "Item ID to delete.", + }, + "item_title": { + "type": "string", + "description": "Item title to delete.", + }, + }, + }, + }, + }, + "proton_pass_totp": { + "handler": proton_pass_totp, + "schema": { + "name": "proton_pass_totp", + "description": "Get the current TOTP (time-based one-time password) code for an item that has TOTP configured.", + "parameters": { + "type": "object", + "properties": { + "share_id": { + "type": "string", + "description": "Vault share ID.", + }, + "vault_name": { + "type": "string", + "description": "Vault name.", + }, + "item_id": { + "type": "string", + "description": "Item ID.", + }, + "item_title": { + "type": "string", + "description": "Item title.", + }, + "uri": { + "type": "string", + "description": "pass:// URI shortcut.", + }, + }, + }, + }, + }, + "proton_pass_inject": { + "handler": proton_pass_inject, + "schema": { + "name": "proton_pass_inject", + "description": "Run a shell command with secrets injected into environment variables from Proton Pass. Uses pass-cli run to resolve pass:// URIs in env vars. Secrets are masked in stdout/stderr by default.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "Command to execute with secret-injected environment variables.", + }, + "env_files": { + "type": "array", + "items": {"type": "string"}, + "description": ".env files to load (later files override earlier).", + }, + "no_masking": { + "type": "boolean", + "description": "Disable automatic secret masking in output. Use with caution.", + }, + }, + "required": ["command"], + }, + }, + }, + "proton_pass_ssh_load": { + "handler": proton_pass_ssh_load, + "schema": { + "name": "proton_pass_ssh_load", + "description": "Load SSH keys stored in Proton Pass into the system's SSH agent. Requires SSH_AUTH_SOCK to be set.", + "parameters": { + "type": "object", + "properties": { + "share_id": { + "type": "string", + "description": "Restrict to vault by share ID.", + }, + "vault_name": { + "type": "string", + "description": "Restrict to vault by name.", + }, + }, + }, + }, + }, + "proton_pass_ssh_agent_start": { + "handler": proton_pass_ssh_agent_start, + "schema": { + "name": "proton_pass_ssh_agent_start", + "description": "Start Proton Pass CLI as the SSH agent (foreground). Sets SSH_AUTH_SOCK to the agent's Unix socket.", + "parameters": { + "type": "object", + "properties": { + "share_id": { + "type": "string", + "description": "Restrict to vault by share ID.", + }, + "vault_name": { + "type": "string", + "description": "Restrict to vault by name.", + }, + }, + }, + }, + }, + "proton_pass_ssh_daemon_start": { + "handler": proton_pass_ssh_daemon_start, + "schema": { + "name": "proton_pass_ssh_daemon_start", + "description": "Start Proton Pass SSH agent as a background daemon process.", + "parameters": { + "type": "object", + "properties": { + "share_id": { + "type": "string", + "description": "Restrict to vault by share ID.", + }, + "vault_name": { + "type": "string", + "description": "Restrict to vault by name.", + }, + "log_file": { + "type": "string", + "description": "Path to log file for daemon output.", + }, + }, + }, + }, + }, + "proton_pass_ssh_daemon_status": { + "handler": proton_pass_ssh_daemon_status, + "schema": { + "name": "proton_pass_ssh_daemon_status", + "description": "Check the status of the Proton Pass SSH agent daemon (running, degraded, or stopped).", + "parameters": {"type": "object", "properties": {}}, + }, + }, + "proton_pass_ssh_daemon_stop": { + "handler": proton_pass_ssh_daemon_stop, + "schema": { + "name": "proton_pass_ssh_daemon_stop", + "description": "Stop the Proton Pass SSH agent background daemon.", + "parameters": {"type": "object", "properties": {}}, + }, + }, + "proton_pass_share_item": { + "handler": proton_pass_share_item, + "schema": { + "name": "proton_pass_share_item", + "description": "Share a Proton Pass item with another user by email.", + "parameters": { + "type": "object", + "properties": { + "share_id": { + "type": "string", + "description": "Vault share ID.", + }, + "vault_name": { + "type": "string", + "description": "Vault name.", + }, + "item_id": { + "type": "string", + "description": "Item ID to share.", + }, + "item_title": { + "type": "string", + "description": "Item title to share.", + }, + "email": { + "type": "string", + "description": "Recipient email address.", + }, + "role": { + "type": "string", + "enum": ["viewer", "editor", "manager"], + "description": "Access role. Default: viewer.", + }, + }, + "required": ["email"], + }, + }, + }, + "proton_pass_generate_password": { + "handler": proton_pass_generate_password, + "schema": { + "name": "proton_pass_generate_password", + "description": "Generate a random password or passphrase and optionally save it to a vault.", + "parameters": { + "type": "object", + "properties": { + "length": { + "type": "integer", + "description": "Password character length (default: 20).", + }, + "include_uppercase": { + "type": "boolean", + "description": "Include uppercase letters (default: true).", + }, + "include_symbols": { + "type": "boolean", + "description": "Include symbols (default: true).", + }, + "passphrase": { + "type": "boolean", + "description": "Generate a passphrase instead of a random password.", + }, + "word_count": { + "type": "integer", + "description": "Number of words for passphrase (default: 4).", + }, + "save": { + "type": "boolean", + "description": "Save the generated item to a vault.", + }, + "vault_name": { + "type": "string", + "description": "Vault to save into (required if save=true).", + }, + "share_id": { + "type": "string", + "description": "Vault share ID to save into.", + }, + "title": { + "type": "string", + "description": "Title for the saved item.", + }, + }, + }, + }, + }, +} + + +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]} <tool_name> [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-pass/scripts/verify.py b/skills/proton-pass/scripts/verify.py new file mode 100644 index 0000000..8925b57 --- /dev/null +++ b/skills/proton-pass/scripts/verify.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Verify proton-pass skill module loads and all tools are registered.""" +import sys +sys.path.insert(0, 'skills/proton-pass/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" \u2713 {name} \u2014 {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_pass_auth_status')) +print(f"\nAuth status dispatch (expected error \u2014 no CLI): success={result.get('success')}") +print(f" error={str(result.get('error', result.get('message', '')))[:120]}") + +result2 = json.loads(tools.dispatch('nonexistent')) +print(f"\nNonexistent tool: error={result2.get('error', '')[:80]}")