{"id":"3a4e51e3-ee3f-4a08-b737-19f1e42c9a6f","entityType":"agent","slug":"clawhub-rickkbarbosa-ssh-executor","name":"ssh-executor","canonicalUrl":"https://www.xpersona.co/agent/clawhub-rickkbarbosa-ssh-executor","canonicalPath":"/agent/clawhub-rickkbarbosa-ssh-executor","generatedAt":"2026-10-10T07:44:41.415Z","source":"CLAWHUB","claimStatus":"UNCLAIMED","verificationTier":"NONE","summary":{"evidence":{"source":"editorial-content","verified":true,"confidence":"high","updatedAt":"2026-10-10T02:16:13.368Z","emptyReason":null},"description":"Execute commands on remote hosts over SSH with structured discovery protocol, vault-integrated auth, and safety guardrails for remote server administration. Use when the user asks to access remote servers, inspect state, map runtime/containers/network/data, or run single commands. Supports SSH keys Skill: ssh-executor Owner: rickkbarbosa Summary: Execute commands on remote hosts over SSH with structured discovery protocol, vault-integrated auth, and safety guardrails for remote server administration. Use when the user asks to access remote servers, inspect state, map runtime/containers/network/data, or run single commands. Supports SSH keys Tags: latest:2.4.2 Version history: v2.4.2 | 2026-07-24T02:37:04.179Z |","descriptionLabel":"Technical summary","evidenceSummary":"Capability contract not published. No trust telemetry is available yet. 1.8K downloads reported by the source. Last updated 10/10/2026.","installCommand":"clawhub skill install s171q3s6m4gq09dzjg1yep0g1n84x3a1:ssh-executor","sourceUrl":"https://clawhub.ai/rickkbarbosa/ssh-executor","homepage":"https://clawhub.ai/rickkbarbosa/skills/ssh-executor","primaryLinks":[{"label":"View on ClawHub","url":"https://clawhub.ai/rickkbarbosa/ssh-executor","kind":"source"},{"label":"Homepage","url":"https://clawhub.ai/rickkbarbosa/skills/ssh-executor","kind":"homepage"}],"safetyScore":84,"overallRank":62,"popularityScore":65,"trustScore":null,"claimedByName":null,"isOwner":false,"seoDescription":"Execute commands on remote hosts over SSH with structured discovery protocol, vault-integrated auth, and safety guardrails for remote server administration. Use"},"coverage":{"evidence":{"source":"public-profile","verified":false,"confidence":"medium","updatedAt":"2026-10-10T02:16:13.368Z","emptyReason":null},"protocols":[{"protocol":"OPENCLEW","label":"OpenClaw","status":"self-declared","notes":"Declared in the public agent profile."}],"capabilities":[],"verifiedCount":0,"selfDeclaredCount":1,"capabilityMatrix":{"rows":[{"key":"OPENCLEW","type":"protocol","support":"unknown","confidenceSource":"profile","notes":"Listed on profile"}],"flattenedTokens":"protocol:OPENCLEW|unknown|profile"}},"adoption":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-10T02:16:13.368Z","emptyReason":null},"stars":null,"forks":null,"downloads":1781,"packageName":null,"latestVersion":"2.4.2","tractionLabel":"1.8K downloads"},"release":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-10T02:16:13.368Z","emptyReason":null},"lastUpdatedAt":"2026-10-10T02:16:13.368Z","lastCrawledAt":"2026-10-10T02:16:13.368Z","lastIndexedAt":null,"nextCrawlAt":"2026-10-11T02:16:13.368Z","lastVerifiedAt":null,"highlights":[{"version":"2.4.2","createdAt":"2026-07-24T02:37:04.179Z","changelog":"- Removed unused file: skill-card.md - Clarified and expanded credential source descriptions, including support for password-based login with explicit `SSH_EXECUTOR_ALLOW_DANGEROUS=1` - Updated security rules: password and sudo operations require explicit user approval, even if credentials already exist - Declared new required capability: access to environment variables for password and agent support - Documented the use of `sshpass` for password-based authentication as an optional dependency - Enhanced documentation for usage, security, and platform setup across multiple environments","fileCount":20,"zipByteSize":52313},{"version":"2.4.1","createdAt":"2026-07-23T17:46:39.021Z","changelog":"- Removed the documentation file skill-card.md. - No changes to functional code; only documentation assets were updated.","fileCount":20,"zipByteSize":47122},{"version":"2.4.0","createdAt":"2026-07-23T16:01:27.105Z","changelog":"# ssh-executor v2.4.0 changelog - Removed the `skill-card.md` file. - The SKILL.md was streamlined: documentation about some credential sources and dangerous operation gating was removed. - `sshpass` was removed from the list of optional binaries in the requirements. - The permissions table now omits the `env` capability, narrowing required environment exposure. - Documentation now omits explicit instructions for direct password-based and sudo-based usage paths.","fileCount":20,"zipByteSize":46701},{"version":"2.3.6","createdAt":"2026-07-23T14:26:33.648Z","changelog":"Version 2.3.6 of ssh-executor - No file or documentation changes detected in this version. - Behavior, features, and documentation remain the same as the previous release.","fileCount":20,"zipByteSize":51386},{"version":"2.3.5","createdAt":"2026-07-23T13:48:16.101Z","changelog":"- Removed the file: skill-card.md - No other functional or behavioral changes in this version.","fileCount":20,"zipByteSize":51119},{"version":"2.3.4","createdAt":"2026-07-23T12:57:17.614Z","changelog":"No code or documentation changes detected in this release. - Version incremented to 2.3.4 with no modifications. - No updates were made to code, configuration, or documentation files. - Existing features and behavior remain unchanged.","fileCount":20,"zipByteSize":51104},{"version":"2.3.3","createdAt":"2026-07-23T12:26:54.329Z","changelog":"- Removed the file skill-card.md. - No functional changes were made to the core logic or security practices. - Documentation was updated to clarify handling of temporary key files: now explicitly notes use of /dev/shm for key material when available, with /tmp as fallback. - Security rules section expanded to detail temp key material storage and cleanup behavior.","fileCount":20,"zipByteSize":51128},{"version":"2.3.2","createdAt":"2026-07-23T04:30:17.309Z","changelog":"## ssh-executor 2.3.2 - Strengthened security: Added documentation and enforcement of the `SSH_EXECUTOR_ALLOW_DANGEROUS` environment variable to explicitly gate password-based SSH and sudo access. - Updated usage instructions: Now requires `SSH_EXECUTOR_ALLOW_DANGEROUS=1` for password auth and sudo; included warnings and error exit code (98) for attempts without the flag. - Clarified handling of sensitive logs and temp key material, with new guidance against writing private keys to `/tmp`; require `/dev/shm` or ssh-agent instead. - Removed the skill-card.md file (no impact on functionality or features).","fileCount":20,"zipByteSize":50729}]},"execution":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No published capability contract is available yet."},"installCommand":"clawhub skill install s171q3s6m4gq09dzjg1yep0g1n84x3a1:ssh-executor","setupComplexity":"low","setupSteps":["Setup complexity is classified as HIGH. You must provision dedicated cloud infrastructure or an isolated VM. Do not run this directly on your local workstation.","Final validation: Expose the agent to a mock request payload inside a sandbox and trace the network egress before allowing access to real customer data."],"contract":{"contractStatus":"missing","authModes":[],"requires":[],"forbidden":[],"supportsMcp":false,"supportsA2a":false,"supportsStreaming":false,"inputSchemaRef":null,"outputSchemaRef":null,"dataRegion":null,"contractUpdatedAt":null,"sourceUpdatedAt":null,"freshnessSeconds":null},"invocationGuide":{"preferredApi":{"snapshotUrl":"https://www.xpersona.co/api/v1/agents/clawhub-rickkbarbosa-ssh-executor/snapshot","contractUrl":"https://www.xpersona.co/api/v1/agents/clawhub-rickkbarbosa-ssh-executor/contract","trustUrl":"https://www.xpersona.co/api/v1/agents/clawhub-rickkbarbosa-ssh-executor/trust"},"curlExamples":["curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-rickkbarbosa-ssh-executor/snapshot\"","curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-rickkbarbosa-ssh-executor/contract\"","curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-rickkbarbosa-ssh-executor/trust\""],"jsonRequestTemplate":{"query":"summarize this repo","constraints":{"maxLatencyMs":2000,"protocolPreference":["OPENCLEW"]}},"jsonResponseTemplate":{"ok":true,"result":{"summary":"...","confidence":0.9},"meta":{"source":"CLAWHUB","generatedAt":"2026-10-10T07:44:41.409Z"}},"retryPolicy":{"maxAttempts":3,"backoffMs":[500,1500,3500],"retryableConditions":["HTTP_429","HTTP_503","NETWORK_TIMEOUT"]}},"endpoints":{"dossierUrl":"https://www.xpersona.co/api/v1/agents/clawhub-rickkbarbosa-ssh-executor/dossier","snapshotUrl":"https://www.xpersona.co/api/v1/agents/clawhub-rickkbarbosa-ssh-executor/snapshot","contractUrl":"https://www.xpersona.co/api/v1/agents/clawhub-rickkbarbosa-ssh-executor/contract","trustUrl":"https://www.xpersona.co/api/v1/agents/clawhub-rickkbarbosa-ssh-executor/trust"}},"reliability":{"evidence":{"source":"runtime-metrics","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No trust, reliability, or runtime telemetry is available."},"trust":{"status":"unavailable","handshakeStatus":"UNKNOWN","verificationFreshnessHours":null,"reputationScore":null,"p95LatencyMs":null,"successRate30d":null,"fallbackRate":null,"attempts30d":null,"trustUpdatedAt":null,"trustConfidence":"unknown","sourceUpdatedAt":null,"freshnessSeconds":null},"decisionGuardrails":{"doNotUseIf":["Contract metadata is missing or unavailable for deterministic execution."],"safeUseWhen":[],"riskFlags":["missing_or_unavailable_contract","trust_data_unavailable","schema_references_missing"],"operationalConfidence":"low"},"executionMetrics":{"observedLatencyMsP50":null,"observedLatencyMsP95":null,"estimatedCostUsd":null,"uptime30d":null,"rateLimitRpm":null,"rateLimitBurst":null,"lastVerifiedAt":null,"verificationSource":null},"runtimeMetrics":{"successRate":null,"avgLatencyMs":null,"avgCostUsd":null,"hallucinationRate":null,"retryRate":null,"disputeRate":null,"p50Latency":null,"p95Latency":null,"lastUpdated":null}},"benchmarks":{"evidence":{"source":"no-benchmark-data","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No benchmark suites or observed failure patterns are available."},"suites":[],"failurePatterns":[]},"artifacts":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"high","updatedAt":"2026-10-10T02:16:13.368Z","emptyReason":null},"readme":"Skill: ssh-executor\n\nOwner: rickkbarbosa\n\nSummary: Execute commands on remote hosts over SSH with structured discovery protocol, vault-integrated auth, and safety guardrails for remote server administration. Use when the user asks to access remote servers, inspect state, map runtime/containers/network/data, or run single commands. Supports SSH keys\n\nTags: latest:2.4.2\n\nVersion history:\n\nv2.4.2 | 2026-07-24T02:37:04.179Z | user\n\n- Removed unused file: skill-card.md\n- Clarified and expanded credential source descriptions, including support for password-based login with explicit `SSH_EXECUTOR_ALLOW_DANGEROUS=1`\n- Updated security rules: password and sudo operations require explicit user approval, even if credentials already exist\n- Declared new required capability: access to environment variables for password and agent support\n- Documented the use of `sshpass` for password-based authentication as an optional dependency\n- Enhanced documentation for usage, security, and platform setup across multiple environments\n\nv2.4.1 | 2026-07-23T17:46:39.021Z | user\n\n- Removed the documentation file skill-card.md.\n- No changes to functional code; only documentation assets were updated.\n\nv2.4.0 | 2026-07-23T16:01:27.105Z | user\n\n# ssh-executor v2.4.0 changelog\n\n- Removed the `skill-card.md` file.\n- The SKILL.md was streamlined: documentation about some credential sources and dangerous operation gating was removed.\n- `sshpass` was removed from the list of optional binaries in the requirements.\n- The permissions table now omits the `env` capability, narrowing required environment exposure.\n- Documentation now omits explicit instructions for direct password-based and sudo-based usage paths.\n\nv2.3.6 | 2026-07-23T14:26:33.648Z | user\n\nVersion 2.3.6 of ssh-executor\n\n- No file or documentation changes detected in this version.\n- Behavior, features, and documentation remain the same as the previous release.\n\nv2.3.5 | 2026-07-23T13:48:16.101Z | user\n\n- Removed the file: skill-card.md\n- No other functional or behavioral changes in this version.\n\nv2.3.4 | 2026-07-23T12:57:17.614Z | user\n\nNo code or documentation changes detected in this release.\n\n- Version incremented to 2.3.4 with no modifications.\n- No updates were made to code, configuration, or documentation files.\n- Existing features and behavior remain unchanged.\n\nv2.3.3 | 2026-07-23T12:26:54.329Z | user\n\n- Removed the file skill-card.md.\n- No functional changes were made to the core logic or security practices.\n- Documentation was updated to clarify handling of temporary key files: now explicitly notes use of /dev/shm for key material when available, with /tmp as fallback.\n- Security rules section expanded to detail temp key material storage and cleanup behavior.\n\nv2.3.2 | 2026-07-23T04:30:17.309Z | user\n\n## ssh-executor 2.3.2\n\n- Strengthened security: Added documentation and enforcement of the `SSH_EXECUTOR_ALLOW_DANGEROUS` environment variable to explicitly gate password-based SSH and sudo access.\n- Updated usage instructions: Now requires `SSH_EXECUTOR_ALLOW_DANGEROUS=1` for password auth and sudo; included warnings and error exit code (98) for attempts without the flag.\n- Clarified handling of sensitive logs and temp key material, with new guidance against writing private keys to `/tmp`; require `/dev/shm` or ssh-agent instead.\n- Removed the skill-card.md file (no impact on functionality or features).\n\nv2.3.1 | 2026-07-23T03:19:34.092Z | user\n\nssh-executor 2.3.1\n\n- Added new troubleshooting documentation: `references/troubleshooting-field-notes.md`.\n- Updated security rules: vault credentials are now explicitly described as being resolved in RAM and passed to SSH/sudo only via stdin pipe; vault binary path validation clarified.\n- Documentation updates to clarify credential handling and improve accuracy in security and operational guidance.\n- No code or functional changes; this is a documentation and security clarification update.\n\nv1.1.3 | 2026-05-04T05:42:56.076Z | user\n\nVersion 1.1.3 of ssh-executor\n\n- No code or documentation changes detected in this release.\n- All files remain unchanged from the previous version.\n\nv1.0.3 | 2026-04-15T06:19:11.604Z | user\n\n- Added \"python3\" as a required binary in the skill's metadata.\n- No other changes detected; all documentation and workflow remain unchanged.\n\nArchive index:\n\nArchive v2.4.2: 20 files, 52313 bytes\n\nFiles: manifest.json (668b), references/docker-diagnostics-without-cli.md (5596b), references/multiplexing-verification.md (3453b), references/remote-backup-cleanup.md (3611b), references/safety.md (5234b), references/security-audit-2026-07-clawhub.md (3469b), references/server-to-server-rsync.md (6280b), references/testing-pitfalls-2026-07-23.md (2834b), references/troubleshooting-field-notes.md (1537b), references/vault-backends.md (3355b), references/vault-key-format.md (2006b), references/vault-ssh-integration.md (4975b), release.json (515b), scripts/ssh-client.py (10886b), scripts/ssh-keys.sh (10506b), scripts/ssh-run-native.sh (16655b), scripts/ssh-run.sh (7710b), skill-card.md (2936b), SKILL.md (34710b), _meta.json (131b)\n\nFile v2.4.2:SKILL.md\n\n---\nname: ssh-executor\ndescription: Execute commands on remote hosts over SSH with structured discovery, pluggable credential backends, and safety guardrails. Supports SSH key-based and password-based auth with multiple credential sources (vault, env vars, direct files). Includes 6-step discovery protocol and standardized JSON output. Host-key verification is strict by default. Cross-platform: works on Hermes Agent, OpenClaw, Claude Code, Codex, and any LLM environment with bash + python3.\nmetadata:\n  platforms: [\"hermes\", \"openclaw\", \"claude-code\", \"codex\", \"generic\"]\n  os: [\"linux\", \"darwin\"]\n  requires: { bins: [\"bash\", \"python3\", \"base64\"], optional_bins: [\"ssh\", \"ssh-agent\", \"ssh-add\", \"ssh-keygen\", \"sshpass\"] }\n---\n\n# SSH Executor\n\nExecute remote commands over SSH securely. Platform-agnostic — configure once, use anywhere.\n\n## Scope\n\n| Use for | Don't use for |\n|---------|---------------|\n| Connecting to Linux servers via alias, IP, user, port | Storing/displaying credentials in chat or logs |\n| Inspection commands (read-only) | Running destructive commands without user confirmation |\n| Maintenance with explicit `--confirm-dangerous` | Bypassing host-key verification (strict by default) |\n| Any credential backend: vault, env vars, key files, password prompts | Exposing private key contents anywhere |\n\n### Credential Sources (Tiered)\n\nThe skill works with **any** credential source. Configure what you have — no mandatory vault dependency.\n\n| Tier | Source | How | Example |\n|------|--------|-----|---------|\n| **1. Vault** | Any vault that outputs to stdout | `--vault-key <name>` + backend script | Bitwarden CLI, 1Password CLI, vault-resolver, HashiCorp Vault |\n| **2. Env vars** | Environment variables | `SSH_PASS`, `SSH_SUDO_PASS`, `SSH_KEY` | CI/CD pipelines, containerized agents |\n| **3. Direct** | Key files, interactive prompts | `--key <path>`, `--ssh-pass-ask`, `--sudo-pass-ask` | Local development, one-off access |\n\nThe default vault backend is **vault-resolver** (Hermes-native, Vaultwarden API). To use another vault:\n\n```bash\n# 1Password CLI\nexport VAULT_RESOLVER_BIN=\"op\"\n\n# Bitwarden CLI\nexport VAULT_RESOLVER_BIN=\"bw\"\n\n# Custom script (must accept JSON on stdin, output JSON on stdout)\nexport VAULT_RESOLVER_BIN=\"/path/to/your/vault-wrapper\"\n```\n\nSee `references/vault-backends.md` for setup guides for each platform.\n\n## Platform Setup\n\n### Hermes Agent / OpenClaw (native)\n```bash\n# vault-resolver is auto-detected at /opt/data/bin/vault-resolver\n# Store a key:\nssh-keys.sh store my-server ~/.ssh/id_rsa\n# Use:\nssh-run.sh --host my-server --vault-key my-server -- 'uptime'\n```\n\n### Claude Code / Codex / Generic LLM\n```bash\n# No vault — use env vars or key files:\nexport SSH_KEY=\"$(cat ~/.ssh/id_rsa)\"\nssh-run.sh --host my-server --user ubuntu --key ~/.ssh/id_rsa -- 'uptime'\n\n# Or with password (requires SSH_EXECUTOR_ALLOW_DANGEROUS=1):\nSSH_EXECUTOR_ALLOW_DANGEROUS=1 SSH_PASS=\"<your-password>\" ssh-run.sh --host my-server --user ubuntu -- 'uptime'\n```\n\n### CI/CD / GitHub Actions\n```bash\nssh-run.sh --host ${{ secrets.SSH_HOST }} \\\n           --user ${{ secrets.SSH_USER }} \\\n           --key <(echo \"${{ secrets.SSH_KEY }}\") \\\n           -- 'deploy.sh'\n```\n\n## Server Discovery\n\nThe remote server inventory (aliases, IPs, ports, users) lives in the **wiki** at `/opt/data/wiki/entities/remote-servers.md`. When the user mentions an alias not in `~/.ssh/config`, check this file first — it contains the full table of all known servers.\n\n**Warning:** The actual `~/.ssh/config` may differ from what the wiki documents. In particular, the `User` directive is often **not** present for individual hosts. Always pass `--user <user>` explicitly in that case. The wiki contains the per-host user table.\n\nDo not use this skill to:\n- Store or transmit passwords in chat, logs, or memory files\n- Expose private key contents in chat, logs, or memory\n- Bypass host key verification without explicit user permission (default is strict)\n- Run destructive commands without user confirmation\n- Use `restore-to-file` unnecessarily — prefer ssh-agent\n\n## Recommended Flow\n\n1. Discover the target: alias, hostname, port, user — from SSH config or the request.\n2. Validate the host with the user before connecting.\n3. Decide risk level: read-only (safe) or mutating (requires confirmation).\n4. Execute with `scripts/ssh-run.sh` and the correct parameters.\n5. Report the result: success, exit code, stdout/stderr, next steps.\n\n## stdout/stderr Contract\n\n`ssh-run.sh` and its backends follow a strict output contract:\n\n| Stream | Content | Format |\n|--------|---------|--------|\n| **stdout** | Pure JSON with command result | `{\"success\": bool, \"exit_code\": int, \"stdout\": \"...\", \"stderr\": \"...\", ...}` |\n| **stderr** | Status messages (vault, warnings, progress) | Free text |\n\n**Rule:** stdout is **always** parseable by `json.load()`. It never contains vault messages, ssh warnings, or any non-JSON text. This enables pipelines like:\n\n```bash\nresult=$(ssh-run.sh --host <your-host> --user <user> --vault-key id-rsa -- 'df -h' 2>/dev/null)\necho \"$result\" | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d['stdout'])\"\n```\n\n## Security Rules\n\n- **Validate host with user.** Confirm hostname/IP before any connection, especially if inferred from config.\n- **Read-only first.** Start with inspection commands. Commands that modify state (sudo, systemctl, rm, apt, docker, firewall, etc.) require `--confirm-dangerous` and explicit user approval.\n- **Mandatory re-confirmation for sudo.** Every `--sudo` execution requires explicit user approval, even if the password is already in the vault. This includes consecutive commands on the same host — never automate sudo.\n- **Dangerous command heuristic is not exhaustive.** Always treat unknown or chained commands with caution.\n- **Private keys never go to chat, logs, stdout, or memory files.** The `ssh-run.sh` JSON output intentionally omits key paths.\n- **Vault credentials are resolved in RAM and passed to SSH/sudo only via stdin pipe.** The vault binary path is validated before execution.\n- **Key disk exposure is minimized but not eliminated in all paths.** Without `ssh-agent`, `ssh-keys.sh restore` writes decrypted key material to a temp file (`/tmp/ssh-vault-*` or `/dev/shm/ssh-vault-*`) that is cleaned up by trap handlers. `restore-to-file` writes to a caller-specified path. Interruption by `kill -9` prevents cleanup — prefer `ssh-agent` to avoid disk exposure entirely.\n- **Sensitive log access requires approval.** Reading `/var/log/auth.log`, `/var/log/secure`, or similar security logs exposes usernames, source IPs, and authentication patterns — treat as privileged telemetry. Always get explicit user approval and consider redacting PII (usernames, IPs) before sharing results.\n- **Prefer `/dev/shm` over `/tmp` for temporary key material.** `/dev/shm` is RAM-backed tmpfs and survives reboots less often than `/tmp`. The scripts use `/dev/shm` when available and fall back to a secure temp directory otherwise. Multiplexing sockets (not key material) in `/tmp` are acceptable.\n\n## MCP Permissions Declaration\n\nThis skill requires the following capabilities. Deploy with least-privilege toolset restrictions:\n\n| Capability | Scope | Justification |\n|-----------|-------|---------------|\n| `terminal` | Bash scripts (`ssh-run.sh`, `ssh-keys.sh`, `ssh-run-native.sh`) | SSH command execution |\n| `file` | Read `~/.ssh/config`, wiki server inventory, temp files | Configuration resolution |\n| `env` | `SSH_PASS`, `SSH_SUDO_PASS`, `SSH_AUTH_SOCK` | Password/env-var auth paths |\n| `web` | vault-resolver API calls (localhost only) | Credential resolution |\n\n**Restricted:** Never grant `delegation` or `cronjob` to this skill — SSH execution must always be directly supervised by the user.\n\n### Dangerous Operations Gate\n\nPassword-based SSH authentication and `sudo` are gated behind the `SSH_EXECUTOR_ALLOW_DANGEROUS` environment variable. Without it set to `1`, these operations return exit code 98:\n\n```bash\n# Enable dangerous operations (password auth + sudo):\nexport SSH_EXECUTOR_ALLOW_DANGEROUS=1\nssh-run.sh --host <host> --sudo --sudo-pass-vault <name> -- 'systemctl restart nginx'\n\n# Without the env var → exit 98 \"requires SSH_EXECUTOR_ALLOW_DANGEROUS=1\"\n```\n\nThis gate ensures the operator explicitly acknowledges the elevated risk at the environment level before any password or sudo operation is possible. Key-based auth (without sudo) does not require this gate.\n\n## Structured Discovery Protocol\n\nFor new server investigations or infrastructure mapping, follow this discovery order — each step answers one question before moving on:\n\n| Step | Question | Typical commands |\n|------|----------|------------------|\n| 1. Identity | Who is this host? What role? | `hostname`, `cat /etc/os-release`, `uname -a` |\n| 2. Runtime | What is running? | `docker ps`, `systemctl list-units --type=service`, `ps aux`, `docker compose ps` |\n| 3. Origin | Where did it come from? Which repo/tag/volumes? | `docker inspect --format '{{.Config.Image}}' <container>`, `docker inspect --format '{{.Mounts}}' <container>`, `docker compose config` |\n| 4. Network | What is listening? | `ss -tlnp`, `docker port <container>`, `iptables -L -n` (with approval) |\n| 5. Data | Where is persistent data? | `df -h`, `lsblk`, `docker volume ls`, `mount` |\n| 6. Register | What do I know for sure vs. what am I inferring? | Structured report (see below) |\n\n**Narrow commands:** each command should answer **one** question. Avoid `docker inspect` without a field filter — prefer `--format '{{.Config.Image}}'` or `'{{.Mounts}}'`. Avoid `find /` or `grep -r /`.\n\nIf at any step you encounter a path with `.env`, `keystore/`, `secrets/`, or credential files — **do not read the contents**, only register its existence.\n\n## Expected Output\n\nWhen finishing a discovery or mapping, report in this format:\n\n```\nHost: <hostname>\nRole: <role description>\n\nRuntime:\n  - <container/service 1> → image:tag, compose: <path>\n  - <container/service 2> → image:tag, compose: <path>\n\nNetwork:\n  - <port>: <protocol> → <description>\n\nData:\n  - <mount>: <host path>\n\nConfirmed facts:\n  - ...\n\nInference / Assumptions:\n  - ... (mark explicitly)\n\nOpen questions:\n  - ...\n\nRequired approvals:\n  - ...\n```\n\nThis separates **evidence** from **interpretation** — any downstream operator can reproduce the facts and question the conclusions.\n\n## Backend behavior with `--vault-key`\n\n**As of 2026-07-22:** `--vault-key` **no longer forces the Python/Paramiko backend**. The native backend (`ssh-run-native.sh`) handles vault keys via `ssh-keys.sh restore` → `ssh-agent`. The Python/Paramiko backend (`ssh-client.py`) is used only as a fallback when `openssh-client` is unavailable.\n\n## Connection Multiplexing (ControlMaster)\n\nTo avoid audit log spam (`auth.log`) — where multiple SSH connections in a few seconds look like a brute-force attack — the native backend now uses **OpenSSH connection multiplexing**:\n\n```bash\n# First command: opens connection + authenticates (1 entry in auth.log)\nssh-run.sh --host <your-host> -- 'hostname'\n\n# Subsequent commands (within 180s): reuse socket (ZERO new entries)\nssh-run.sh --host <your-host> -- 'df -h'\nssh-run.sh --host <your-host> -- 'docker ps'\n```\n\n**Parameters:**\n- `--control-persist <seconds>` — how long the master socket stays alive after the last command (default: **180s**)\n- `--control-close` — close the master socket immediately after the command (for clean audit session termination)\n\n**Socket path:** `/tmp/ssh-mux-%r@%h:%p` (e.g. `/tmp/ssh-mux-root@10.0.0.5:22`)\n\n**Important:** Multiplexing only works when all commands to the same host use the **same** user and port. If alternating between `root@host` and `user@host`, each combination gets its own socket.\n\nActive multiplexing verification: `references/multiplexing-verification.md`.\n\n## Sudo Password Resolution\n\nFor commands requiring `sudo` without configuring `NOPASSWD` on the server, the skill supports three password sources, resolved in priority order:\n\n### 1. Vault (most secure)\n```bash\n# Store once:\nvault-resolver write sudo-<your-host> sudo_password=\"my-password\"\n\n# Use:\nSSH_EXECUTOR_ALLOW_DANGEROUS=1 ssh-run.sh --host <your-host> --user <user> --vault-key <key-name> \\\n           --sudo --sudo-pass-vault <your-host> --confirm-dangerous \\\n           -- 'systemctl restart nginx'\n```\n\n### 2. Environment variable (for pipelines)\n```bash\nSSH_EXECUTOR_ALLOW_DANGEROUS=1 SSH_SUDO_PASS=\"my-password\" ssh-run.sh --host <your-host> --user <user> --vault-key <key-name> \\\n    --sudo --confirm-dangerous -- 'systemctl restart nginx'\n```\n\n### 3. Interactive prompt via LLM\n```bash\n# LLM calls with --sudo-pass-ask, script fails with instructions\nSSH_EXECUTOR_ALLOW_DANGEROUS=1 ssh-run.sh --host <your-host> --user <user> --vault-key <key-name> \\\n    --sudo --sudo-pass-ask --confirm-dangerous -- 'systemctl restart nginx'\n# → exit 97: \"Ask the user for the password, then retry with SSH_SUDO_PASS\"\n\n# LLM asks user, then retries:\nSSH_EXECUTOR_ALLOW_DANGEROUS=1 SSH_SUDO_PASS=\"<user-provided>\" ssh-run.sh ... --sudo --confirm-dangerous -- 'systemctl restart nginx'\n```\n\n**Priority:** vault → `SSH_SUDO_PASS` env var → `--sudo-pass-ask`\n\n**Security:**\n- Password resolved in RAM, never on disk\n- Sent via `echo '<pass>' | sudo -S -p ''` — password never appears on the command line (`ps`)\n- `-p ''` suppresses sudo password prompt (avoids stderr pollution)\n- Original command is base64-encoded to avoid quote escaping issues\n- `--sudo` automatically enables `--confirm-dangerous` (no extra flag needed)\n- ⚠️ **Every `--sudo` execution must be re-confirmed by the user.** Even with the password in the vault, the LLM must never run sudo commands without explicit approval on each call. This includes consecutive commands on the same host — each `--sudo` requires fresh confirmation.\n\n**Vault format:** item `sudo-<name>` with field `sudo_password` (plain text, not base64).\n\n## SSH Password Authentication\n\n**⚠️ Security tradeoff:** Password-based SSH is weaker than key-based auth. Use only when keys are not available and the user has explicitly approved. Prefer key-based auth (`--vault-key` or `--key`) whenever possible.\n\nWhen no SSH key is available, the skill supports password-based authentication via `sshpass`, with the same three-source pattern:\n\n### 1. Vault\n```bash\n# Store once:\nvault-resolver write ssh-<your-host> ssh_password=\"my-password\"\n\n# Use:\nSSH_EXECUTOR_ALLOW_DANGEROUS=1 ssh-run.sh --host <your-host> --user <user> --ssh-pass-vault <your-host> -- 'df -h'\n```\n\n### 2. Environment variable\n```bash\nSSH_EXECUTOR_ALLOW_DANGEROUS=1 SSH_PASS=\"my-password\" ssh-run.sh --host <your-host> --user <user> -- 'df -h'\n```\n\n### 3. Interactive prompt via LLM\n```bash\nSSH_EXECUTOR_ALLOW_DANGEROUS=1 ssh-run.sh --host <your-host> --user <user> --ssh-pass-ask -- 'df -h'\n# → exit 96: \"Ask the user for the password, then retry with SSH_PASS\"\n```\n\n**Priority:** vault → `SSH_PASS` env var → `--ssh-pass-ask`\n\nWhen key-based auth is available (`--vault-key` or `--key`), it takes precedence over password auth. Password auth is only attempted when no key is configured.\n\n**Requires:** `sshpass` (`apt install sshpass` / `brew install sshpass`).\n\n**Vault format:** item `ssh-<name>` with field `ssh_password` (plain text).\n\n## Vault Integration\n\nThe skill supports pluggable vault backends via the `VAULT_RESOLVER_BIN` environment variable. The default backend is `vault-resolver` (Vaultwarden API, Hermes-native), but you can point it at any CLI that accepts JSON on stdin and returns JSON on stdout.\n\n### Quick setup per platform\n\n| Platform | Vault backend | Setup |\n|----------|--------------|-------|\n| **Hermes Agent** | vault-resolver (built-in) | Zero config — auto-detected |\n| **Bitwarden** | `bw` CLI | `export VAULT_RESOLVER_BIN=\"bw\"` |\n| **1Password** | `op` CLI | `export VAULT_RESOLVER_BIN=\"op\"` |\n| **HashiCorp Vault** | `vault` CLI | `export VAULT_RESOLVER_BIN=\"/path/to/vault-wrapper\"` |\n| **None** | Key files only | Skip `--vault-key`, use `--key` or env vars |\n\n### vault-resolver specifics (Hermes Agent)\n\nPath: `/opt/data/bin/vault-resolver` (hardcoded in scripts; override with `VAULT_RESOLVER_BIN`).\n\nKey storage convention:\n- SSH keys: item `ssh-<name>` with field `ssh_private_key` (base64)\n- SSH passwords: item `ssh-<name>` with field `ssh_password` (plain text)  \n- Sudo passwords: item `sudo-<name>` with field `sudo_password` (plain text)\n\nFor self-signed certificate environments: vault-resolver uses `SSL_CTX.check_hostname=False`. **Only safe on trusted local networks** (LAN, VPN, localhost).\n\nSee `references/vault-backends.md` for custom backend integration and API contract.\n\n### Usage with --vault-key\n\n```bash\n# ALWAYS pass --vault-key to connect with a vault key\nscripts/ssh-run.sh --host <your-host> --vault-key id-rsa -- 'command'\n\n# Does NOT work without --vault-key — IdentityFile from SSH config may not exist\n```\n\n### Fallback without vault-key (when key is not in vault)\n\nIf `--vault-key` fails because the SSH key was never stored in Vaultwarden (`Expecting value` error), use direct SSH with the local key on disk:\n\n```bash\nssh -o StrictHostKeyChecking=yes -i ~/.ssh/id_rsa <user>@<host> '<command>'\n```\n\nIf this is first contact with the host, verify its fingerprint manually before connecting. Add it to `~/.ssh/known_hosts` via `ssh-keyscan` or accept interactively. Never use `StrictHostKeyChecking=no` for unknown hosts.\n\nThis bypasses the vault-resolver dependency. The native backend with `ssh-agent` is the current default. Use this fallback **only for read-only commands** (df, ps, cat, ls). After confirming it works, consider storing the key in the vault:\n\n```bash\nssh-keys.sh store id-rsa ~/.ssh/id_rsa\n```\n\nThis way, `--vault-key` will work normally in future sessions.\n\n**Warning:** The `ssh-run.sh` wrapper flags commands with `2>/dev/null`, `|`, `&&`, `||` as dangerous (exit 99). When using direct SSH, these redirections work without blocking.\n\n### Known Pitfalls\n\n| Problem | Cause | Solution |\n|---------|-------|----------|\n| `Permission denied (publickey)` | `--vault-key` not passed; tries IdentityFile from SSH config | Add `--vault-key <name>` |\n| `'ssh-id-rsa' not found` | vault-resolver looks in `login.password`, but key is in `notes` | Use `ssh-keys.sh store` to recreate with custom field `ssh_private_key` (see `references/vault-key-format.md`) |\n| `Failed to resolve vault key <name>: Expecting value` | The SSH key **does not exist** in Vaultwarden — item `ssh-<name>` was never created, is empty, or has invalid format | Fall back to direct SSH with local key (see \"Fallback without vault-key\" below), then store the key in vault with `ssh-keys.sh store <name> ~/.ssh/id_rsa` |\n| `No module named 'paramiko'` | Python/Paramiko backend (fallback, used only when `openssh-client` is unavailable on the system) but paramiko not installed | `uv pip install --target ~/.openclaw/workspace/.ssh-pylib paramiko` |\n| `timed out` (SSH connection) | Wrong port or hostname — Python backend wasn't resolving SSH config | Now resolves HostName/Port/User from `~/.ssh/config` automatically |\n| `authentication failed` | (a) SSH key was never deployed to server's `authorized_keys` — Paramiko rejects during key exchange; (b) vault-resolver returned key from wrong field (notes vs field) | (a) Fall back to direct SSH with local key (see \"Fallback without vault-key\" below) and confirm the key works; then optionally store with `ssh-keys.sh store <name> <path>`; (b) Use `ssh-keys.sh store` to recreate in correct format |\n| `ssh-keys.sh` timeout (~30s) | vault-resolver authentication via bw CLI was slow (~18s) | Direct API rewrite reduces to ~2s with session cache |\n| `PermissionError: /tmp/.bw_hermes/api_session.json` | vault-resolver cache created by different UID (e.g. 1000 vs 10000) | Set `BITWARDENCLI_APPDATA_DIR` to a writable directory, e.g. `BITWARDENCLI_APPDATA_DIR=/opt/data/tmp/bw-cache` before calling vault-resolver or ssh-run.sh with --vault-key |\n| `cat` with redirections or short pipes blocked as \"dangerous\" (exit 99) | `ssh-run.sh` heuristic detects `2>/dev/null`, pipes `|` or `&&`/`||` as potentially dangerous, even for read-only commands | Simplify the command: remove redirections, split into separate commands, or run with `ssh -i ~/.ssh/id_rsa` directly without the wrapper |\n| `find` command with pipe blocked as \"dangerous\" (exit 99) | False positive from heuristic: `find ... | while read d; do ts=$(stat -c %Y \"$d\"); ...` contains `stat` + pipes that trigger the dangerous command detector, even though it's read-only | Simplify the command to avoid pipes with `stat`: (a) use `ls -1` + separate loop, (b) run in two SSH calls, or (c) use `--confirm-dangerous` **only when the command is provably read-only** |\n| `ControlSocket /tmp/ssh-mux-... already exists` | Master socket from a previous session is still active (within ControlPersist) | Normal and expected — means multiplexing is working. To force a new socket, use `--control-close` on the previous command or wait for TTL to expire |\n| **`Host key verification failed` despite correct known_hosts** | SSH config has `UserKnownHostsFile /dev/null` which discards all host keys | The script now forces `UserKnownHostsFile` when `StrictHostKeyChecking=yes`. Ensure host keys are in `${HOME}/.ssh/known_hosts` |\n| **Group membership not updated (e.g. `sudo`, `docker`)** | ControlMaster socket caches the group list from the initial connection | Close the socket first: `ssh -O exit <host>` or `--control-close`, then reconnect for a fresh session with updated groups |\n| `ControlPersist` doesn't work with different hosts even if same IP | `ControlPath` uses `%h` (hostname), not IP | Use the same alias/hostname in all calls. If you need to switch between alias and IP, standardize on the `~/.ssh/config` alias |\n| `sudo: a terminal is required` even with `--pty` | `--pty` allocates a PTY, but if sudo asks for a password, the command hangs waiting for input | Configure `NOPASSWD` in sudoers **or** use `--sudo` with a password source: `--sudo-pass-vault`, `SSH_SUDO_PASS` env var, or `--sudo-pass-ask` |\n| `--sudo requires a password source` (exit 97) | No vault item, no env var, and no `--sudo-pass-ask` flag provided | Provide one of: `--sudo-pass-vault <name>`, `SSH_SUDO_PASS` env var, or `--sudo-pass-ask` |\n| `Failed to resolve sudo password from vault: sudo-<name>` (exit 97) | The item `sudo-<name>/sudo_password` does not exist in Vaultwarden | Create the item: `vault-resolver write sudo-<name> sudo_password=\"password\"` |\n| `SSH password auth requires SSH_EXECUTOR_ALLOW_DANGEROUS=1` (exit 98) | Password-based SSH auth attempted without environment opt-in | Set `export SSH_EXECUTOR_ALLOW_DANGEROUS=1` to acknowledge the elevated risk, then retry. Key-based auth does not require this gate. |\n| `sudo requires SSH_EXECUTOR_ALLOW_DANGEROUS=1` (exit 98) | sudo attempted without environment opt-in | Set `export SSH_EXECUTOR_ALLOW_DANGEROUS=1` to acknowledge the elevated risk, then retry with `--confirm-dangerous`. |\n| `ssh-run.sh` JSON output contains vault messages (\"Retrieving SSH key...\") | Old version of `ssh-keys.sh` (< 2026-07-22) wrote info messages to stdout | Update to the current version: all `cmd_restore()` messages now go to stderr. Stdout contains only JSON |\n| `--vault-key` loads key but falls back to Python backend (no multiplexing) | `ssh-agent` is not running. `ssh-keys.sh restore` detects the absence and creates a temp file for the Python backend, which lacks ControlMaster | Start `ssh-agent` first: `eval \"$(ssh-agent -s)\"`. Without an active agent, each `--vault-key` generates a new TCP connection = auth.log spam |\n| `Permission denied (publickey)` with wrong resolved_user | `~/.ssh/config` has no `User` directive for the host, and the alias doesn't specify a user. SSH uses the local system user | Pass `--user <user>` explicitly: `ssh-run.sh --host <your-host> --user <user> --vault-key <key-name> -- 'command'`. Or add `User <user>` to the `Host` entry in `~/.ssh/config` |\n| `sshpass: command not found` (exit 127) when using `--ssh-pass-*` | `sshpass` is not installed on the system | Install: `apt install sshpass` (Debian/Ubuntu) or `brew install sshpass` (macOS). sshpass is only needed for password-based SSH auth; key-based auth does not require it |\n| `Permission denied (password)` with `--ssh-pass-*` | SSH server does not allow password authentication (`PasswordAuthentication no` in sshd_config) | Enable `PasswordAuthentication yes` on the server, or use key-based auth instead |\n| `Host key verification failed` (exit 255) on first contact | Default is strict (`StrictHostKeyChecking=yes`). Host not in `~/.ssh/known_hosts`. | Manually verify the host fingerprint, then run: `ssh-keyscan <host> >> ~/.ssh/known_hosts`. Do NOT use `--host-key-checking no` as a shortcut. |\n| `paramiko.ssh_exception.SSHException: Server ... not found in known_hosts` (Python backend) | Python backend uses `RejectPolicy` by default — same strict behaviour as native | Use native backend if possible (has multiplexing). If Python is required, verify host fingerprint and add to `~/.ssh/known_hosts`, or use `--host-key-checking no` only with explicit user approval. |\n| `restore-to-file` leaves key on disk after crash | `ssh-keys.sh restore-to-file` writes decrypted key to a caller-specified path. Interruption (kill -9) prevents cleanup. | Prefer `ssh-keys.sh restore` (loads into ssh-agent, no disk). Use `restore-to-file` only as last resort. Verify cleanup after use. |\n| `shred: command not found` (macOS) during key cleanup | macOS does not ship `shred`. The fallback `rm -f` is used but does not securely overwrite blocks. | Acceptable on macOS (APFS encryption at rest mitigates). On Linux, ensure `coreutils` is installed. |\n| `restore-to-file` fails with \"Refusing to write private key to system directory\" | Path validation rejects system dirs: `/etc`, `/boot`, `/sys`, `/proc`, `/dev`, `/run` | Use a path under `/tmp/` or your home directory. |\n| `restore-to-file` warns about non-tmp path | Output path not under `/tmp` or `/dev/shm` — key will persist across reboots | Use `/tmp/` for temporary key material. |\n| `⚠️ SECURITY WARNING: Host-key checking disabled` in stderr | `--host-key-checking no` was passed — connection is MITM-vulnerable | Confirm with the user that they understand and accept the risk. Prefer adding the host key to `known_hosts` instead. |\n| `Host key verification failed` despite host key in known_hosts | `~/.ssh/config` has `UserKnownHostsFile /dev/null` — SSH ignores known hosts. Strict checking has no trust store. | Native backend now forces `-o UserKnownHostsFile=${HOME}/.ssh/known_hosts` when strict. Grep config for `UserKnownHostsFile` directives pointing to `/dev/null`. See `references/testing-pitfalls-2026-07-23.md`. |\n| `ssh-keys.sh restore` exits 1 but prints \"✓ loaded\" (ghost failure) | `set -e` + trap with `shred -u` on already-deleted temp file. EXIT trap fires after success path deleted file. | Fixed: trap uses `rm -f`. Explicit `shred -u` runs in code paths; trap is safety net. See `references/testing-pitfalls-2026-07-23.md`. |\n| Temp key survives `kill -9` | `trap` cannot catch SIGKILL. If the process is hard-killed during `ssh-keys.sh restore`, the temp file at `/dev/shm/ssh-vault-*` (or `/tmp/ssh-vault-*` as fallback) remains. | Use `ssh-keys.sh cleanup` to list stale keys and `ssh-keys.sh cleanup --force` to shred them. The files are also cleaned on next normal invocation when the same key name is restored. |\n\n### Ensuring vault-resolver is accessible\n\nssh-executor scripts use `/opt/data/bin/vault-resolver` (hardcoded path).\n\n```bash\nexport PATH=\"/opt/data/bin:$PATH\"\n```\n\n## Server-to-Server rsync (without ForwardAgent)\n\n> **⚠️ DEPRECATED PATTERN — see `references/server-to-server-rsync.md` for security warning and preferred alternatives.**\n\nWhen you need to copy files **between two remote servers** and there is no\nssh-agent running locally (making ForwardAgent impossible), prefer the SSH\ntunneled variant (ForwardAgent) or pull-via-jump approach. Copying a private\nkey to a remote server is a last resort — use only with a dedicated\nsingle-purpose key and explicit user approval.\n\n## Bundled Resources\n\n### `scripts/ssh-run.sh`\nMain remote execution script. Automatically selects backend:\n- **openssh-client native** when `ssh`/`ssh-agent`/`ssh-add` are available (includes vault-key via `ssh-agent`)\n- **Python + Paramiko** as fallback (works without SSH binaries on the system)\n- **Connection multiplexing** enabled by default (ControlMaster, 180s) to avoid audit log spam\n\n```bash\nscripts/ssh-run.sh --host <host> [--user <user>] [--port <port>] \\\n                   [--key <path>] [--vault-key <name>] \\\n                   [--timeout <sec>] [--host-key-checking <yes|no>] \\\n                   [--confirm-dangerous] \\\n                   [--control-persist <sec>] [--control-close] \\\n                   [--sudo --sudo-pass-vault <name>] \\\n                   -- '<remote command>'\n\nscripts/ssh-run.sh --list-aliases              # list aliases from ~/.ssh/config\n```\n\n`--vault-key <name>` retrieves the SSH key from Vaultwarden (item `ssh-<name>`) and loads it into `ssh-agent`. With an active agent, the key stays in memory. Without `ssh-agent`, a temp file in RAM-backed `/dev/shm` is used as fallback (cleaned by trap handlers; see security caveats).\n`--control-persist <sec>` how long the multiplexing socket stays alive (default: 180).\n`--control-close` closes the master socket immediately after the command.\n`--pty` allocates a pseudo-terminal (needed for `sudo` in non-interactive sessions).\n`--sudo` runs the command with `sudo` using password from vault, env var, or user prompt.\n`--sudo-pass-vault <name>` Vaultwarden item name with the sudo password (`sudo-<name>/sudo_password`).\n`--sudo-pass-ask` signals the LLM to ask the user for the password (retry with `SSH_SUDO_PASS` env var).\n`--ssh-pass-vault <name>` SSH password from Vaultwarden item `ssh-<name>/ssh_password` (used when no key is configured).\n`--ssh-pass-ask` signals the LLM to ask the user for the SSH password (retry with `SSH_PASS` env var).\n\n### `scripts/ssh-keys.sh`\nManages SSH keys in Vaultwarden.\n\n```bash\nscripts/ssh-keys.sh store <name> <path>          # store local key in vault\nscripts/ssh-keys.sh restore <name>               # load from vault into agent (or temp file)\nscripts/ssh-keys.sh restore-to-file <name> <out> # write from vault to file\nscripts/ssh-keys.sh cleanup                         # list stale temp key files\nscripts/ssh-keys.sh cleanup --force                 # shred stale temp key files\nscripts/ssh-keys.sh agent-status                 # show keys loaded in agent\n```\n\n### `references/multiplexing-verification.md`\nVerification procedure to confirm ControlMaster multiplexing is active: 6-step checklist (socket timestamp, mux PID, ss, auth.log), failure signals, and quick diagnostic command.\n\n### `references/vault-backends.md`\nAPI contract and setup guides for pluggable vault backends: vault-resolver (Hermes), Bitwarden CLI, 1Password CLI, HashiCorp Vault wrapper, and no-vault mode.\n\n### `references/vault-ssh-integration.md`\nDetailed architecture of how SSH keys are stored in Vaultwarden and loaded into memory. Read when you need to understand the security model or full flow.\n\n### `references/docker-diagnostics-without-cli.md`\n\nTechniques for diagnosing Docker containers remotely **without** access to the\nDocker CLI (user without docker group, without sudo NOPASSWD). Lists 10 command\ntechniques (`cgroup`, `/proc/PID`, `ip neigh`, `ss`, `/dev/tcp`, docker-compose,\nconfig files) and a real-world MariaDB case with `network_mode: host` that wasn't\nlistening on any port.\n### `references/safety.md`\nDetailed security rules for remote execution. Read when in doubt about what is considered destructive or how to handle sensitive hosts.\n\n### `references/security-audit-2026-07-clawhub.md`\nComplete ClawHub SkillSpector audit history across versions. Documents all findings, fixes, and false-positive classifications from v2.1.0 (47 findings) through v2.3.6.\n\n---\n\n## Editions (v2.4.0+)\n\nThe skill is split into two editions, built from the same source via `build.sh`:\n\n| Edition | Package | Auth | Sudo | Lines |\n|---------|---------|------|------|-------|\n| **Base** | `ssh-executor-<ver>.zip` | Key only | No | 270 |\n| **Full** | `ssh-executor-full-<ver>.zip` | Key + password | Yes | 481 |\n\n**Base** is the default. No sudo, no password auth, no `sshpass` — minimal attack surface.  \n**Full** adds sudo + SSH password auth behind `SSH_EXECUTOR_ALLOW_DANGEROUS=1`.\n\n### Source layout\n\n```\nscripts/\n├── ssh-run-native.sh        ← full (sudo + password)\n├── ssh-run-native-base.sh   ← stripped (key-only)\n├── ssh-run.sh / ssh-run-base.sh\n├── ssh-keys.sh / ssh-client.py  ← shared\n\nSKILL.md / SKILL.base.md     ← full / stripped docs\nreferences/\n├── safety.md / safety.base.md\n└── ...\n\nbuild.sh                     ← bash build.sh <version>\n```\n\nDesign: separate files over marker-based stripping. Simpler to maintain and audit.\nClawHub SkillSpector security audit results (47 findings). Documents all high-confidence vulnerabilities found and how they were fixed in v2.2.0. Read when maintaining or extending the skill to avoid reintroducing known issues: host-key bypass, credential mismanagement, key exfiltration, undeclared MCP capabilities.\n\n### `references/remote-backup-cleanup.md`\nRemote backup cleanup pattern with `find -mtime +N -delete`. Covers the permission model (write access to directory vs. file ownership), solutions for `Permission denied`, and diagnostic commands with `namei`. Refer to when deleting old files in remote backup directories.\n\n## Example Requests That Should Trigger This Skill\n\nThese phrases require **explicit user invocation** — the skill should NOT auto-trigger from casual conversation about servers.\n\n- \"ssh-executor: check uptime on the production server\"\n- \"Run ssh-run.sh to inspect docker ps on the app server\"\n- \"Use the SSH skill to store my id_rsa key as 'prod-key'\"\n- \"Access the server via ssh-executor and check journalctl\"\n- \"ssh-executor: systemctl restart nginx on web-server (with confirmation)\"\n- \"Sync backups from app-server to backup-server using ssh-executor rsync pattern\"\n\nThe skill triggers on phrases that explicitly reference **ssh-executor** or the **ssh-run.sh / ssh-keys.sh** scripts. Casual mentions of \"server\", \"deploy\", or \"SSH\" alone should NOT activate this skill.\n\nFile v2.4.2:_meta.json\n\n{\n  \"ownerId\": \"kn7e7q1wce41djeky0k2zscsw184xh21\",\n  \"slug\": \"ssh-executor\",\n  \"version\": \"2.4.2\",\n  \"publishedAt\": 1784860624179\n}\n\nFile v2.4.2:references/docker-diagnostics-without-cli.md\n\n# Docker Diagnostics Without Docker CLI\n\nWhen the remote SSH user is **not in the docker group** and **has no sudo\nNOPASSWD** — but you still need to diagnose containers, networks, and services.\n\n## Techniques\n\n### 1. List active containers via cgroup\n\n```bash\nls /sys/fs/cgroup/system.slice/docker-*.scope 2>/dev/null | while read f; do\n  id=$(echo \"$f\" | grep -oP \"docker-\\K[a-f0-9]{12}\")\n  echo \"Container ID: $id\"\ndone\n```\n\n⚠️ Shows ALL active containers (not just running — any process in the\ncgroup). Useful as a starting point.\n\n### 2. Identify processes inside a container\n\n```bash\n# PIDs in the container\ncat /sys/fs/cgroup/system.slice/docker-<FULL_ID>.scope/cgroup.procs\n\n# What each PID executes\nfor pid in $(cat /sys/fs/cgroup/system.slice/docker-<ID>.scope/cgroup.procs); do\n  cmd=$(cat /proc/$pid/cmdline 2>/dev/null | tr \"\\0\" \" \" | head -c 200)\n  echo \"PID $pid: $cmd\"\ndone\n```\n\n### 3. Determine the container's network_mode\n\nCompare the process network namespace with the host's:\n\n```bash\nhost_ns=$(readlink /proc/1/ns/net)\ncontainer_ns=$(readlink /proc/<PID>/ns/net)\nif [ \"$host_ns\" = \"$container_ns\" ]; then\n  echo \"network_mode: host\"\nelse\n  echo \"network_mode: bridge (or other)\"\nfi\n```\n\n### 4. Find container IPs via bridge ARP\n\nKnowing which bridge is active (via `ip addr show`):\n\n```bash\n# List active bridges\nip addr show | grep -E \"^[0-9]+: br-|^[0-9]+: docker\" | grep UP\n\n# Show ARP neighbors (active IPs)\nip neigh show dev br-<ID>\n```\n\nExample output:\n```\n172.18.0.5 lladdr de:9a:bb:2b:2a:32 REACHABLE\n172.18.0.7 lladdr 66:1d:5b:2c:c2:08 STALE\n```\n\n**REACHABLE/STALE** IPs = active containers. **FAILED** = IP does not exist.\n\n### 5. Discover which process listens on which port\n\n```bash\nss -tlnp    # TCP listening, with PID\nss -ulnp    # UDP\n```\n\n⚠️ Without root, `ss -p` does not show the process (empty column). Use the port\nas a clue and cross-reference with `/proc/PID/cmdline`.\n\n### 6. Test TCP connectivity without tools\n\n```bash\ntimeout 2 bash -c \"echo > /dev/tcp/<IP>/<PORT>\" 2>/dev/null && echo \"OPEN\"\n```\n\nUseful for quickly scanning ports on a bridge or host — **only on networks you own or have explicit authorization to probe**:\n\n```bash\n# ⚠️ Network scanning — verify authorization before running\nfor ip in 172.18.0.{1..10}; do\n  timeout 1 bash -c \"echo > /dev/tcp/$ip/3306\" 2>/dev/null && echo \"$ip:3306 OK\"\ndone\n```\n\n### 7. Read container configuration via docker-compose\n\nNot every container was started with compose, but when it was:\n\n```bash\ncat /path/docker-compose.yml\n```\n\nPay special attention to:\n- `network_mode:` (host vs bridge)\n- `networks:` → `driver:` (host = shares host IP)\n- `ports:` (mapping)\n- `extra_hosts:` (hostname resolution)\n- `environment:` / `env_file:` (configuration variables)\n\n### 8. Check internal configuration files\n\nFor services like MySQL/MariaDB, even without container access:\n\n```bash\n# Process has config args in cmdline\ncat /proc/<PID>/cmdline | tr \"\\0\" \" \"\n\n# Check default config (host filesystem)\ncat /etc/mysql/mysql.conf.d/mysqld.cnf  # Host MySQL\n# Container may have a DIFFERENT config\n\n# For MariaDB in network_mode host:\n# The container default bind-address is 0.0.0.0\n# But the docker run command may override with --bind-address=127.0.0.1\n```\n\n### 9. Check ports on the host (outside container)\n\n```bash\n# Ports listening on IPv4\nss -tlnp -4\n```\n\nQuick common ports table:\n\n| Port | Service | Typical container |\n|-------|---------|-----------|\n| 3306 | MySQL/MariaDB | mysql-db, web-db |\n| 80 | HTTP | nginx, apache |\n| 443 | HTTPS | nginx (via reverse proxy) |\n| 11211 | Memcached | memcached |\n| 10051 | Zabbix trapper | zabbix-server |\n\n### 10. Check MariaDB/MySQL datadir\n\nWhen the container mounts a volume:\n\n```bash\nls /var/lib/<project>/mariadb/    # or mysql/\n# List databases:\nls /var/lib/<project>/mariadb/ | grep -v \"^#\" | grep -v \"^aria\\|^ib_\\|^mysql\\|^performance\\|^sys\"\n```\n\n## Real Case: MariaDB container with network_mode host\n\nA real-world example: a `mysql-db` container (mariadb:10.5) was configured with\n`network_mode: host` and mysqld was running (active PID), but it was **not\nlistening on any port 3306** — neither TCP nor Unix socket.\n\nDiagnosis performed:\n1. `cat /proc/<PID>/cmdline` → confirmed mysqld with MariaDB args\n2. `readlink /proc/<PID>/ns/net == readlink /proc/1/ns/net` → network_mode host\n3. `ss -tlnp` → **zero** port 3306 on any IP\n4. `cat /etc/mysql/mysql.conf.d/mysqld.cnf` → bind-address = 127.0.0.1 on host\n   (but this is the host config, not the container's)\n5. `ls /var/lib/<project>/mariadb/` → datadir present with databases\n6. Bridge 172.18.0.0/16 active (with containers) vs 172.19.0.0/16 linkdown\n\n**Conclusion:** The MariaDB inside the container did not complete initialization\ncorrectly or the default `bind-address` (0.0.0.0 in official mariadb) was\noverridden. Requires `docker logs <container>` (with docker access) for\nfinal diagnosis.\n\n## Pitfalls\n\n| Problem | Cause | Solution |\n|----------|-------|---------|\n| `ls /proc/PID/fd/` empty | PID runs as different UID (e.g. 999 = mysql) | Try `sudo ls` or use other techniques |\n| `nsenter` access denied | No CAP_SYS_ADMIN permission | Do not use nsenter without sudo |\n| `ip neigh` shows FAILED | Container with network_mode host (no dedicated IP) | Container shares host IP; look for port on host |\n| Bridge linkdown but IP configured | Docker network without containers (created but unused) | Check which bridge has REACHABLE/STALE traffic |\n| `docker logs` unavailable | No docker CLI access | Only option: `journalctl` or process logs in datadir |\n\nFile v2.4.2:references/multiplexing-verification.md\n\n# Multiplexing Verification\n\nProcedure to confirm that ControlMaster multiplexing is working correctly.\n\n## Quick Checklist (6 steps)\n\n### 1. Does the local socket exist?\n\n```bash\nls -la /tmp/ssh-mux-<user>@<hostname>:<port>\n# Ex: /tmp/ssh-mux-root@10.0.0.5:22\n```\n\n**Expected:** UNIX socket (`srw-------`) with the first connection's timestamp.\n\n### 2. Does the socket timestamp stay unchanged across subsequent commands?\n\n```bash\nstat -c '%Y' /tmp/ssh-mux-<user>@<host>:<port>  # before\nssh-run.sh --host <host> --user <user> --vault-key <key> -- 'date'\nstat -c '%Y' /tmp/ssh-mux-<user>@<host>:<port>  # after — same value!\n```\n\n**Expected:** same epoch timestamp before and after.\n\n### 3. Only 1 mux process for the host?\n\n```bash\nps aux | grep \"[s]sh.*mux.*<host>\"\n```\n\n**Expected:** exactly 1 process `ssh: /tmp/ssh-mux-... [mux]`.\n\n### 4. Are TCP connections on the server consistent?\n\n```bash\nssh-run.sh --host <host> --user <user> --vault-key id-rsa -- 'ss -tn sport = :22'\n```\n\n**Expected:** the number of ESTABLISHED connections to the Hermes IP does not increase with each command.\n\n### 5. Does auth.log have only 1 Accepted per window?\n\n```bash\nssh-run.sh --host <host> --user <user> --vault-key <key> \\\n           --sudo --sudo-pass-vault <name> \\\n           -- 'sudo grep \"sshd.*Accepted.*<user>\" /var/log/auth.log | tail -5'\n```\n\n**Expected:** only 1 `Accepted publickey` entry for the entire batch of commands within the ControlPersist window.\n\n### 6. Is the JSON output clean (no vault pollution)?\n\n```bash\nresult=$(ssh-run.sh --host <host> --user <user> --vault-key id-rsa -- 'hostname' 2>/dev/null)\necho \"$result\" | python3 -c \"import sys,json; d=json.load(sys.stdin); print('OK:', d['stdout'].strip())\"\n```\n\n**Expected:** successful parse, no \"Retrieving SSH key...\" messages in stdout.\n\n## Signs that multiplexing is NOT active\n\n| Symptom | Likely cause |\n|---------|-------------|\n| Socket does not exist after command | `ssh-agent` is not running → Python backend (no ControlMaster) |\n| Multiple sockets with different timestamps | Different `--user` across calls → each user@host combination creates its own socket |\n| Socket exists but timestamp changes per command | `ControlPersist` expired between calls (>180s) |\n| `Permission denied (publickey)` | Wrong `--user` (local user instead of remote) |\n\n## Quick diagnostic command\n\n```bash\n# Close old socket, test 3 commands, verify\nssh -o ControlPath=/tmp/ssh-mux-<user>@%h:%p -O exit <user>@<host> 2>/dev/null || true\n\nTS1=$(bash ssh-run.sh --host <host> --user <user> --vault-key <key> --control-persist 180 -- 'hostname' 2>/dev/null | python3 -c \"import sys,json; print(json.load(sys.stdin)['stdout'].strip())\")\nS1=$(stat -c '%Y' /tmp/ssh-mux-<user>@<host>:<port> 2>/dev/null)\n\nTS2=$(bash ssh-run.sh --host <host> --user <user> --vault-key <key> -- 'date' 2>/dev/null | python3 -c \"import sys,json; print(json.load(sys.stdin)['stdout'].strip())\")\nS2=$(stat -c '%Y' /tmp/ssh-mux-<user>@<host>:<port> 2>/dev/null)\n\nTS3=$(bash ssh-run.sh --host <host> --user <user> --vault-key <key> -- 'whoami' 2>/dev/null | python3 -c \"import sys,json; print(json.load(sys.stdin)['stdout'].strip())\")\nS3=$(stat -c '%Y' /tmp/ssh-mux-<user>@<host>:<port> 2>/dev/null)\n\necho \"Socket epochs: $S1 $S2 $S3\"\necho \"Multiplexing: $([ \"$S1\" == \"$S2\" ] && [ \"$S2\" == \"$S3\" ] && echo '✅ ACTIVE' || echo '❌ FAILED')\"\necho \"Mux PID: $(ps aux | grep '[s]sh.*mux.*<host>' | awk '{print $2}')\"\n```\n\nFile v2.4.2:references/remote-backup-cleanup.md\n\n# Remote Backup Cleanup via SSH\n\n> **⚠️ DESTRUCTIVE OPERATIONS:** All commands in this document delete data permanently. \n> - **Always dry-run first** with `-ls` or `-printf` to verify what will be deleted\n> - **Require explicit user confirmation** before running any command with `-delete`\n> - Pass `--confirm-dangerous` AND set `SSH_EXECUTOR_ALLOW_DANGEROUS=1` when using `ssh-run.sh` for these commands\n> - See `safety.md` for the full confirmation policy\n\nClean up old backups on remote servers using `find -mtime +N -delete`.\n\n## Basic pattern\n\n```bash\n# With ssh-executor (requires SSH_EXECUTOR_ALLOW_DANGEROUS=1 + --confirm-dangerous):\nSSH_EXECUTOR_ALLOW_DANGEROUS=1 ssh-run.sh --host <host> --user <user> --vault-key <key> \\\n    --confirm-dangerous -- 'find /srv/backup -type f -mtime +15 -delete'\n\n# Direct SSH (manual, no guardrails):\nssh <user>@<host> 'find /srv/backup -type f -mtime +15 -delete'\n```\n\nThe `-delete` flag only works after `-type f` (prevents accidentally deleting directories).\n\n## Permission model — common pitfall\n\nDeleting a file does **not** require write permission on the **file** — it requires write permission (`w`) on the **directory** that contains it.\n\n### Quick diagnosis\n\n```bash\n# View complete hierarchy permissions\nnamei -l /srv/backup/2026-06-13/backup_file.tar.gz\n```\n\n### Typical scenario\n\n```\ndrwxr-xr-x root backup  srv/backup/        # backup group has r-x, missing w\ndrwxr-xr-x root backup  2026-06-13/        # backup group has r-x, missing w\n-rw-r--r-- root backup  file.tar.gz         # group read-only — irrelevant\n```\n\nEven if the **remote user** is in the `backup` group (via `groups` or `id`) and the **files** are `root:backup`, deletion fails with `Permission denied` if the directory lacks `w` for the group.\n\n### Solutions (in order of preference)\n\n| Approach | Requirement | Risks |\n|-----------|-----------|--------|\n| **sudo** | Remote user's sudo password | Simplest, but needs interaction |\n| **chmod g+w on directories** | Directory owner or sudo | Permission stays open; requires directory owner |\n| **Cron job as root** | Backup service runs as root | Ideal for automated routine |\n| **ACL** | Filesystem with ACL support | `setfacl -m g:backup:rwx /srv/backup` |\n\n### Real case example\n\nA common scenario encountered in production:\n\n- Server with backup directories owned by `root:backup`, permissions `drwxr-xr-x`\n- The remote user is in the `backup` group but lacks write permission on directories\n- Backup files are owned by `root:root` (or `root:backup`)\n- Deletion fails without sudo because directories lack `w` for the `backup` group\n\nThis is not a file permission issue — it's a **directory write permission** issue.\nThe solution is sudo, `chmod g+w` on directories, ACLs, or a cron job as root.\n\n## Useful commands\n\n```bash\n# Dry-run: list files older than 15 days with size\nssh <host> 'find /srv/backup /archive/backup -type f -mtime +15 -ls'\n\n# Count and total size\nssh <host> 'find /srv/backup /archive/backup -type f -mtime +15 -printf \"%s\\n\" | awk \"{sum+=\\$1} END {printf \\\"Files: %d, Size: %.2f GB\\n\\\", NR, sum/1073741824}\"'\n\n# Delete (runs as user, fails without write permission on directories)\nssh <host> 'find /srv/backup /archive/backup -type f -mtime +15 -delete'\n\n# Delete with sudo (requires password)\nssh <host> 'sudo find /srv/backup /archive/backup -type f -mtime +15 -delete'\n```\n\n## Note on `-delete`\n\n`find ... -delete` implies `-depth`, so it processes subdirectories before parents. Safe for files. For leftover empty directories, a second `find ... -type d -empty -delete` cleans up the remains.\n\nFile v2.4.2:references/safety.md\n\n# SSH Executor Safety Notes\n\n## Default posture\n\n- **Read-only first.** Start with inspection commands (`hostname`, `uptime`, `df -h`, `journalctl -n 100`, `docker ps`).\n- **Explicit confirmation before any mutation.** State-changing commands require the user to see and approve the exact command before `--confirm-dangerous` is passed.\n- **Least-privilege SSH accounts.** Use a dedicated read-only or low-privilege SSH account for inspection when available. Only escalate to a privileged account for authorized mutation.\n- The script's dangerous-command heuristic (`is_dangerous_command`) is a **best-effort pattern check**, not a guarantee. It can produce both false positives and (more critically) false negatives. An empty check does not mean the command is safe.\n- Prefer SSH aliases and existing `~/.ssh/config` entries.\n- Prefer private keys over passwords.\n- Keep timeouts short unless the user clearly expects a long-running command.\n- Let ssh config resolve host, user, port, and identity file when an alias already exists.\n\n## Host-key policy\n\n- **Default is strict: `StrictHostKeyChecking=yes`.** Unknown hosts are rejected — the user must explicitly verify and accept the host key before connecting.\n- `yes`: **default and only safe option** for most deployments. Requires the host key to be in `~/.ssh/known_hosts`.\n- `no`: **do not use** unless the user explicitly understands and accepts the man-in-the-middle risk. Required only for ephemeral environments where host keys change frequently.\n- Existing ssh config policy wins if you do not pass `--host-key-checking`, but the script's default is `yes` (strict) when no config entry exists.\n- **`accept-new` has been removed.** It was a security antipattern — auto-trusting unknown hosts on first contact without fingerprint verification.\n\n## Commands that always need confirmation\n\nAsk the user before running any command that:\n- modifies files, permissions, or ownership (`rm`, `mv`, `chmod`, `chown`, `tee`, `dd`, `truncate`, `sed -i`)\n- restarts, stops, or disables services (`systemctl restart|stop|disable`, `service`, `initctl`)\n- installs, removes, or upgrades packages (`apt`, `apt-get`, `dnf`, `yum`, `apk`, `pacman`, `dpkg`, `rpm`)\n- reboots or shuts down the host (`reboot`, `shutdown`, `poweroff`)\n- uses `sudo`\n- deletes, rotates, or truncates data (`truncate`, `dd`, logrotate actions)\n- changes containers, databases, firewalls, or network state (`docker rm|down|kill`, `kubectl delete`, `iptables`, `ufw`, `firewall-cmd`, `ip link set`, `ip addr add|del`, `nmcli`)\n- writes to disk or pipes output to a file (`>`, `>>`, `| tee`, `dd`)\n- executes code on the remote host that was not explicitly reviewed (`curl | bash`, `wget -O- | sh`, `eval`, `source`)\n\n**When in doubt, treat the command as dangerous and ask for confirmation.**\n\nThe script returns a guardrail error (exit code 99) for commands matching the heuristic unless `--confirm-dangerous` is present.\n\n## Credential hygiene\n\n- **Use dedicated least-privilege SSH keys** for remote inspection. Create a separate key/alias with read-only permissions instead of reusing a full-access key.\n- **Do not paste private keys or passwords into chat** under any circumstance.\n- The script's JSON output intentionally **omits key paths, SSH config paths, and resolved identity file paths** to avoid leaking credential metadata to logs, chat, or memory files.\n- If an SSH alias resolves to a privileged account by default, configure a separate alias for inspection or explicitly pass `--user` with a low-privilege user.\n- **Stale temp key cleanup:** If `ssh-keys.sh restore` is interrupted by `kill -9`, temp key files at `/dev/shm/ssh-vault-*` or `/tmp/ssh-vault-*` may persist. Run `ssh-keys.sh cleanup` to list them and `ssh-keys.sh cleanup --force` to shred and remove.\n\n## Sudo password handling\n\nWhen `sudo` is required but `NOPASSWD` is not configured on the server:\n- Store the sudo password in Vaultwarden as `sudo-<name>/sudo_password` (plain text field).\n- Use `--sudo --sudo-pass-vault <name>` — the script resolves the password in RAM and pipes it to `sudo -S` via stdin.\n- **Exposure is minimized, not eliminated:** the password never appears in chat, JSON stdout, `ps aux` argv, or on disk. However, it briefly exists in the shell's memory (`SUDO_PASS` variable) and the stdin pipe buffer. A core dump or `/proc/<pid>/environ` could theoretically expose it if the process is inspected at the right instant. For maximum safety, prefer `NOPASSWD` in sudoers.\n- `--sudo` requires `--confirm-dangerous` for explicit user approval.\n\n## stdout/stderr contract\n\nAll scripts follow a strict output contract for security and parseability:\n\n| Stream | Content | Format |\n|--------|---------|--------|\n| **stdout** | Pure JSON result | `{\"success\": bool, \"exit_code\": int, \"stdout\": \"...\", \"stderr\": \"...\", ...}` |\n| **stderr** | Status messages (vault, warnings, progress) | Free text |\n\n- **Never** parse stdout without going through JSON — vault status messages on stdout indicate an outdated `ssh-keys.sh`.\n- The `\"command\"` field in JSON always shows the **original** user command, even when wrapped with sudo/base64 internally.\n- The `\"sudo\": true` field indicates privileged execution.\n\nFile v2.4.2:references/security-audit-2026-07-clawhub.md\n\n# ClawHub Security Audit — July 2026\n\n**Auditor:** SkillSpector by NVIDIA\n**Scope:** ssh-executor skill v2.1.0\n**Total findings:** 47 (25 detailed, 22 hidden)\n\n## Findings Fixed in v2.2.0 (High Severity)\n\n| # | Category | Confidence | Finding | Fix |\n|---|----------|-----------|---------|-----|\n| 1 | Tool Poisoning | 99% | Documented behavior contradicts safety model (passwords, key restore, sudo, auto-accept host keys) | Description updated to match actual capabilities; host-key default → strict |\n| 2 | Credential Mismanagement | 99% | Header forbids passwords but docs instruct storage | Header clarified: \"passwords for automation may be stored in Vaultwarden with explicit approval\" |\n| 3 | Key Exfiltration | 99% | Rsync workflow copies private key to remote server | server-to-server-rsync.md deprecated; ForwardAgent preferred |\n| 4 | Host-Key Bypass | 99% | AutoAddPolicy in Python, accept-new in native | Default RejectPolicy (Python), StrictHostKeyChecking=yes (native), accept-new removed |\n| 5 | Destructive Commands | 94% | sudo find ... -delete without confirmation | Warning banner in remote-backup-cleanup.md |\n| 6 | Undeclared MCP | 92% | Shell, file, env-var access undeclared | MCP Permissions Declaration section added |\n\n## Findings Fixed in v2.2.1 (Medium Severity)\n\n| # | Category | Confidence | Finding | Fix |\n|---|----------|-----------|---------|-----|\n| 7 | Temp Key Cleanup | 93-95% | trap EXIT only, kill -9 bypasses | trap EXIT INT TERM HUP + shred -u |\n| 8 | restore-to-file Path | 92% | Writes to any caller-specified path | Path validation: rejects /etc, /boot, /sys, /proc, /dev, /run; warns non-tmp |\n| 9 | Missing User Warning | — | No warning on --host-key-checking no | Explicit stderr MITM warning |\n| 10 | Pass-through Bug | — | --host-key-checking ignored by Python backend | ssh-run.sh now captures and passes to ssh-client.py |\n| 11 | SSL/TLS | — | check_hostname=False without caveat | \"Only safe on trusted local networks\" |\n\n## Confirmed Safe (No Action Needed)\n\n- vault-resolver uses direct API with session cache — not vulnerable to bw CLI dependency issues\n- ssh-run.sh JSON output omits key paths, SSH config paths, resolved identity files\n- --sudo auto-enables --confirm-dangerous, no separate flag needed\n- Password-based sudo pipes via stdin, never appears in ps aux\n\n## Remaining Low-Severity / Informational\n\n47 total findings minus 11 fixed = 36 remaining. The remaining are either:\n- Low-severity informational (e.g., \"skill enables shell execution\" — by design)\n- False positives (e.g., \"SSH key in vault is stored\" — Vaultwarden is AES-256 encrypted at rest)\n- Duplicates of the 11 already fixed\n\n## Lessons for Skill Authors\n\n1. **Default-deny for host keys.** Never auto-accept in any backend.\n2. **Description must match implementation.** If the skill supports password auth, say so honestly — don't pretend it's key-only.\n3. **Trap all signals, not just EXIT.** INT, TERM, and HUP are common; kill -9 remains a known gap.\n4. **Validate caller paths.** Any restore-to-file action should reject system directories.\n5. **Declare MCP permissions.** Even if the platform doesn't enforce them yet, document what capabilities the skill needs.\n6. **Warn explicitly on unsafe choices.** --host-key-checking no should produce a visible stderr warning.\n7. **Deprecate, don't hide.** The key-copy rsync pattern is still documented but clearly marked as deprecated with preferred alternatives.\n\nFile v2.4.2:references/server-to-server-rsync.md\n\n# Server-to-Server rsync via Temporary Key\n\n> **⚠️ SECURITY WARNING — DEPRECATED PATTERN**\n>\n> Copying a private SSH key to a remote server (even temporarily) is a **credential staging vulnerability**. If the remote server is compromised, the attacker gains access to all servers that key can authenticate to.\n>\n> **Preferred alternatives (in order):**\n> 1. **SSH tunneled with ForwardAgent** (section below) — no key copy, keys stay on client\n> 2. **Pull-via-jump** (section below) — data passes through client, no key on remote\n> 3. **Temporary key copy** — LAST RESORT, only with a dedicated single-purpose key that has minimal access\n>\n> **If you must use the temporary key pattern:**\n> - Create a **dedicated single-purpose key** with access ONLY to the destination server\n> - Never use your primary key\n> - Verify cleanup: `ssh server-a 'ls -la /tmp/transfer_key'` after rsync\n> - The key file WILL PERSIST if the process is interrupted (kill -9, network drop, crash)\n\nWhen you need to sync data **between two remote servers** but local\nForwardAgent is not working (no ssh-agent running) and you have no root\nto install rsync locally.\n\n## Step Zero: Verify source and destination\n\nBefore running rsync, **always confirm**:\n\n1. **The source path exists** on server A — the user might refer to a\n   path that no longer exists (e.g. `/var/www/html` changed to `/var/www/`):\n   ```bash\n   ssh user@server-a 'ls -la /path/source/' 2>&1\n   ```\n   If it fails, inspect `/var/www/` or `/srv/` to find the actual structure.\n\n2. **The destination directory is writable** by the user on server B:\n   ```bash\n   ssh -p <PORT> user@server-b 'touch /path/dest/.test_write && rm /path/dest/.test_write' 2>&1\n   ```\n   If it fails (Permission denied):\n   - Try `sudo mkdir -p /path/dest/` (some servers have NOPASSWD)\n   - If sudo **also** fails (needs password), **use an alternative path**\n     where the user already has write permission (e.g. `~/backup/` or\n     `/home/user/backup/`)\n   - Inform the user about the alternative path and offer future adjustment\n\n## The Problem\n\n```\nYou (client)\n  ├── have the SSH key on disk\n  ├── NO ssh-agent running\n  └── CANNOT install packages (no sudo/root)\n       │\n       ▼\n   Server A ──── ??? ────► Server B\n   (source)                (destination)\n```\n\n`rsync` does not support two remote destinations directly. ForwardAgent\ndoes not work if there is no agent running locally to forward.\n\n**⚠️  Do NOT copy private keys to remote servers.** See the safe variants below.\n\n## Variants\n\n### Via SSH tunneled with ForwardAgent (preferred)\n\nIf ssh-agent is running and server-a accepts ForwardAgent:\n\n```bash\nssh -A user@server-a \\\n  'rsync -avz -e \"ssh -p <DEST_PORT> -o StrictHostKeyChecking=yes\" \\\n     /path/source/ \\\n     user@server-b:/path/dest/'\n```\n\n### Pull via jump (data passes through client)\n\nWhen the above is not viable (e.g. server-a has no rsync):\n\n```bash\n# Pull from server-a to local /tmp/\nrsync -avz -e \"ssh -i ~/.ssh/your-key\" \\\n  user@server-a:/path/source/ /tmp/staging/\n\n# Push from local /tmp/ to server-b\nrsync -avz -e \"ssh -i ~/.ssh/your-key -p <PORT>\" \\\n  /tmp/staging/ user@server-b:/path/dest/\n\n# Clean up staging\nrm -rf /tmp/staging\n```\n\n⚠️ **Downside:** all traffic passes through the client twice.\n\n<details>\n<summary>⚠️  DANGEROUS: Temporary key copy (LAST RESORT — click to expand)</summary>\n\n> **🚫  CREDENTIAL STAGING VULNERABILITY — READ BEFORE USING**\n>\n> Copying a private SSH key to a remote server (even temporarily) exposes\n> credentials to a host you may not fully trust. If server-a is compromised,\n> the attacker gains access to every server that key can authenticate to.\n>\n> **Only proceed if ALL of the following are true:**\n> - [ ] You created a **dedicated single-purpose key** with access ONLY to server-b\n> - [ ] Your primary key is NOT used — this is a throwaway key scoped to one destination\n> - [ ] ForwardAgent and pull-via-jump are genuinely impossible\n> - [ ] You have explicit user approval\n> - [ ] You will verify cleanup: `ssh server-a 'ls -la /dev/shm/transfer_key'` after rsync\n>\n> **The key file WILL PERSIST if the process is interrupted** (kill -9, network drop, crash).\n\n```bash\n# 1. Copy the key to the source server (use /dev/shm, not /tmp)\nscp ~/.ssh/single-purpose-key user@server-a:/dev/shm/transfer_key\n\n# 2. Set correct permission (SSH requires 600)\nssh user@server-a 'chmod 600 /dev/shm/transfer_key'\n\n# 3. Execute rsync from server-a → server-b\nssh user@server-a \\\n  'rsync -avz --delete --progress \\\n     -e \"ssh -i /dev/shm/transfer_key -p <DEST_PORT> -o StrictHostKeyChecking=yes\" \\\n     /path/source/ \\\n     user@<SERVER_B>:/path/dest/'\n\n# 4. Remove the temporary key and verify\nssh user@server-a 'shred -u /dev/shm/transfer_key && echo \"CLEANUP VERIFIED\"'\n```\n\n</details>\n\n## Pitfalls\n\n| Problem | Cause | Solution |\n|----------|-------|---------|\n| `Permission denied` at step 3 | Key does not have 600 permission on server A | Run `chmod 600 /dev/shm/transfer_key` |\n| `rsync: command not found` (local) | Client without rsync, no sudo | Use pull-via-jump instead |\n| `IO error encountered -- skipping file deletion` | File with denied permission on source server (e.g. Docker volume) | Ignore — `--delete` skips, remove manually if needed |\n| False positive: `--delete` flagged as MEDIUM risk | Tool detects `rsync --delete` as destructive | Explain it's a mirror (not a wipe), ask for approval |\n| `rsync error: some files/attrs were not transferred (code 23)` | Docker volumes (pgdata, db/mysql) without read permission | Normal for volumes — source code was transferred; verify with `du -sh` |\n| Key cleanup fails and key stays on server A | Script interrupted mid-way | Always verify after: `ssh server-a 'ls -la /dev/shm/transfer_key'` |\n\n## Checklist\n\n- [ ] Does server A have `rsync` installed? (99% of Linux servers do)\n- [ ] Does the SSH alias resolve? Test with `ssh <alias> 'echo OK'` before scp\n- [ ] Does the copied key have 600 permission?\n- [ ] After rsync, was the key removed?\n- [ ] Verify destination with `ls -la` + `du -sh`\n- [ ] Does server A have outbound access to server B (firewall?)\n- [ ] Does the source path exist? (confirm with `ls -la` before rsync)\n\nFile v2.4.2:references/testing-pitfalls-2026-07-23.md\n\n# Testing Pitfalls — Discovered 2026-07-23\n\nPitfalls found during live deployment testing of ssh-executor v2.2.1.\n\n## 1. UserKnownHostsFile /dev/null Nullifies StrictHostKeyChecking\n\n**Symptom:** `Host key verification failed` (exit 255) even though the host key is in `~/.ssh/known_hosts`.\n\n**Root cause:** `~/.ssh/config` has `UserKnownHostsFile /dev/null` — SSH effectively has no trust store. When our skill sets `StrictHostKeyChecking=yes` (hardening v2.2.0), strict checking fails because there's nothing to check against.\n\n**Fix applied:** In `ssh-run-native.sh`, when `HOST_KEY_CHECKING=yes`, the script now forces:\n```\n-o UserKnownHostsFile=${HOME}/.ssh/known_hosts\n```\nThis overrides the `/dev/null` from the SSH config and points to the real known_hosts file. A `touch` ensures the file exists (SSH refuses nonexistent UserKnownHostsFile paths).\n\n**Checklist:**\n- [ ] Grep SSH config for `UserKnownHostsFile` directives\n- [ ] Ensure `${HOME}/.ssh/known_hosts` is writable\n- [ ] Use `ssh-keyscan` to add host keys before connecting\n\n## 2. Ghost Exit Code 1 from ssh-keys.sh restore\n\n**Symptom:** `ssh-keys.sh restore id-rsa` prints \"✓ SSH key loaded into ssh-agent\" to stderr (success messages) but returns exit code 1 instead of 0. This causes `ssh-run-native.sh` to incorrectly route to the \"Failed to restore SSH key\" error handler (exit 98).\n\n**Root cause:** Combined effect of `set -euo pipefail` + trap + double cleanup:\n\n1. Success path in `cmd_restore()` does `shred -u \"$tmp_key\"` + `rm -f \"$tmp_key\"` → deletes temp file\n2. Prints \"✓ loaded\" success messages  \n3. Calls `exit 0`\n4. `exit 0` triggers EXIT trap: `shred -u \"$tmp_key\" 2>/dev/null; rm -f \"$tmp_key\"`\n5. File is already deleted → `shred -u` fails\n6. `set -e` catches the trap failure and converts to exit 1\n\n**Fix applied:** Changed trap from `shred -u ...; rm -f ...` to just `rm -f`. The `rm -f` on a missing file always succeeds (exit 0). The `shred -u` secure wipe still happens in the explicit code paths (success + error branches) before the trap fires. The trap is now purely a safety net for interrupted execution — it removes the temp file but does not attempt secure wipe (acceptable since /tmp is typically tmpfs/RAM).\n\n## 3. --host-key-checking Silently Ignored by Python Backend\n\n**Symptom:** Using `--host-key-checking no` with the Python/Paramiko backend had no effect — the backend always used its default policy.\n\n**Root cause:** `ssh-run.sh` (dispatcher) had `--host-key-checking) shift 2 ;; # ignored in Python backend`. The variable was never captured or passed to `ssh-client.py`.\n\n**Fix applied:** \n- `ssh-run.sh`: Capture `HOST_KEY_CHECKING` variable, pass to Python backend via `--host-key-checking \"$HOST_KEY_CHECKING\"`\n- `ssh-client.py`: Already supports the flag (added in v2.2.0), defaults to `RejectPolicy`\n\nFile v2.4.2:references/troubleshooting-field-notes.md\n\n# Troubleshooting Field Notes\n\nProduction deployment and audit remediation learnings.\n\n---\n\n## Multiplexing + stale group membership\n\n**Symptom:** `usermod -aG sudo <user>` succeeds, but sudo still fails.\n**Cause:** ControlMaster socket from before group change.\n**Fix:** `ssh -O exit user@host` then retry — new session gets new groups.\n\n---\n\n## UserKnownHostsFile /dev/null\n\n**Symptom:** StrictHostKeyChecking=yes rejects known hosts.\n**Cause:** SSH config has `UserKnownHostsFile /dev/null`.\n**Fix:** Skill now overrides to real known_hosts when strict mode is active (v2.2.1+).\n\n---\n\n## trap + shred + set -e = phantom exit 1\n\n**Symptom:** ssh-keys.sh restore prints success but returns exit 1.\n**Cause:** EXIT trap runs `shred -u` on already-deleted file, `set -e` converts to exit 1.\n**Fix (v2.2.1):** `rm -f` in trap, shred runs explicitly in code path.\n\n---\n\n## Sudo requires --confirm-dangerous (v2.3.1+)\n\n**Before:** --sudo auto-set CONFIRM_DANGEROUS=1.\n**Now:** --sudo blocks with exit 99 unless --confirm-dangerous is passed.\n**LLM flow:** present command → user approves → retry with --confirm-dangerous.\n\n---\n\n## VAULT_RESOLVER_BIN validation (v2.3.1)\n\n**Risk:** Env var flows directly to subprocess.run.\n**Fix:** `shutil.which()` for bare names, `os.path.isfile()` + `os.access(X_OK)` for paths.\n\n---\n\n## Paramiko RejectPolicy + known_hosts (v2.3.1)\n\n**Before:** RejectPolicy without loaded known_hosts meant everything rejected.\n**Fix:** Load `~/.ssh/known_hosts` + `/etc/ssh/ssh_known_hosts` into Paramiko client.\n\nFile v2.4.2:references/vault-backends.md\n\n# Vault Backend Integration\n\nThis skill supports **any** credential vault via the `VAULT_RESOLVER_BIN` environment variable. The backend must accept a JSON request on stdin and return a JSON response on stdout.\n\n## API Contract\n\n### Input (stdin)\n```json\n{\n  \"ids\": [\"item-name/field-name\", ...]\n}\n```\n\n### Output (stdout)\n```json\n{\n  \"values\": {\n    \"item-name/field-name\": \"<base64-encoded value>\",\n    ...\n  }\n}\n```\n\n### Exit codes\n- `0`: success\n- Non-zero: failure (skill will handle gracefully)\n\n## Built-in Backends\n\n### vault-resolver (Hermes Agent default)\n\nZero-config on Hermes Agent. Wraps Vaultwarden's API directly.\n\n```bash\n# Auto-detected — no setup needed\nssh-run.sh --host my-server --vault-key my-key -- 'uptime'\n```\n\nKey naming convention: `ssh-<name>/ssh_private_key` (base64), `ssh-<name>/ssh_password` (plain text), `sudo-<name>/sudo_password` (plain text).\n\n## Custom Backend Wrappers\n\n### Bitwarden CLI (`bw`)\n\n```bash\nexport VAULT_RESOLVER_BIN=\"bw\"\n\n# Requires bw CLI + session:\nbw login\nexport BW_SESSION=$(bw unlock --raw)\n```\n\nThe skill calls `bw` directly — ensure your items follow the naming convention or adapt with a wrapper.\n\n### 1Password CLI (`op`)\n\n```bash\nexport VAULT_RESOLVER_BIN=\"op\"\n\n# Requires op CLI + signin:\nop signin\n```\n\n### HashiCorp Vault\n\n```bash\n# Wrapper script at /usr/local/bin/vault-wrapper:\ncat > /usr/local/bin/vault-wrapper << 'SCRIPT'\n#!/usr/bin/env bash\n# Reads JSON from stdin, resolves vault paths, outputs JSON to stdout\npython3 - << 'PY'\nimport json, subprocess, sys, os\n\nreq = json.load(sys.stdin)\nresult = {\"values\": {}}\n\nfor id_str in req.get(\"ids\", []):\n    # Map ssh-executor naming to Vault paths\n    # ssh-mykey/ssh_private_key → secret/ssh/mykey\n    parts = id_str.split(\"/\")\n    item, field = parts[0], parts[1]\n    \n    # Remove prefix\n    if item.startswith(\"ssh-\"):\n        name = item[4:]\n        vault_path = f\"secret/ssh/{name}\"\n    elif item.startswith(\"sudo-\"):\n        name = item[5:]\n        vault_path = f\"secret/sudo/{name}\"\n    else:\n        vault_path = f\"secret/{item}\"\n    \n    try:\n        p = subprocess.run(\n            [\"vault\", \"kv\", \"get\", \"-field=\" + field, vault_path],\n            capture_output=True, text=True, timeout=10\n        )\n        if p.returncode == 0:\n            result[\"values\"][id_str] = p.stdout.strip()\n    except Exception:\n        pass\n\nprint(json.dumps(result))\nPY\nSCRIPT\nchmod +x /usr/local/bin/vault-wrapper\n\nexport VAULT_RESOLVER_BIN=\"/usr/local/bin/vault-wrapper\"\n```\n\n## No Vault (Key Files Only)\n\nIf you don't use a vault, skip `--vault-key` entirely:\n\n```bash\n# Direct key file\nssh-run.sh --host my-server --user ubuntu --key ~/.ssh/id_rsa -- 'uptime'\n\n# Environment variable\n# ⚠️  WARNING: env vars can leak via shell history, /proc, crash reports, and child processes.\n#    Prefer vault-based auth (--ssh-pass-vault) for production. Use env vars only in ephemeral CI.\nSSH_PASS=\"<your-password>\" ssh-run.sh --host my-server --user ubuntu -- 'uptime'\n\n# Interactive prompt\nssh-run.sh --host my-server --user ubuntu --ssh-pass-ask -- 'uptime'\n# → LLM will ask the user, then retry with SSH_PASS\n```\n\n## Testing Your Backend\n\n```bash\n# Test that your backend works:\necho '{\"ids\": [\"ssh-test/ssh_private_key\"]}' | $VAULT_RESOLVER_BIN resolve\n\n# Expected output: {\"values\": {\"ssh-test/ssh_private_key\": \"<base64>\"}}\n```\n\nFile v2.4.2:references/vault-key-format.md\n\n# SSH Key Format in Vaultwarden\n\n## Expected Format\n\nThe `ssh-keys.sh` script expects SSH keys stored as **custom fields**, not in the notes field.\n\n| Field | Content | Format |\n|-------|---------|--------|\n| `ssh_private_key` | Private key | base64-encoded |\n| `ssh_public_key` | Public key (optional) | base64-encoded |\n\n## How Keys Should Be Stored\n\nAlways use `ssh-keys.sh store <name> <path>` to store a key:\n\n```bash\nssh-keys.sh store prod-server ~/.ssh/id_ed25519_prod\n```\n\nThis creates a vault item named `ssh-prod-server` with proper fields:\n- `ssh_private_key` (base64 of the private key)\n- `ssh_public_key` (base64 of the .pub file, if exists)\n\n## When Keys Are in Notes\n\nIf a key was stored manually (e.g., via Vaultwarden UI, pasted into Secure Notes),\nit won't be found by `ssh-keys.sh restore`. Two options:\n\n1. **Re-store properly** (recommended):\n   ```bash\n   # ⚠️  WARNING: Writing private key to disk — use /dev/shm (RAM-backed)\n   #    to avoid persistence on physical storage. Verify cleanup after.\n   vault-resolver get ssh-id-rsa/notes > /dev/shm/key\n   chmod 600 /dev/shm/key\n   # Re-store in correct format (key loaded into vault, never exposed in chat)\n   ssh-keys.sh store id-rsa /dev/shm/key\n   shred -u /dev/shm/key\n   ```\n\n2. **Resolve via notes fallback** (if vault-resolver has the patch):\n   ```bash\n   vault-resolver get ssh-id-rsa/notes\n   ```\n\n## Resolution Flow\n\nWhen `ssh-run.sh --vault-key <name>` is called:\n\n1. `ssh-run-native.sh` → `ssh-keys.sh restore <name>`\n2. `ssh-keys.sh` calls `vault-resolver resolve` with key `ssh-<name>/ssh_private_key`\n3. vault-resolver looks for a custom field named `ssh_private_key`\n4. If not found and fallback is active, checks other fields then `notes`\n\n## Troubleshooting\n\nIf restore says \"not found\":\n```bash\n# List all vault items matching ssh\nvault-resolver resolve  # with JSON: {\"ids\":[\"ssh-*/ssh_private_key\"]}\n\n# Check what fields the item actually has\n# Use vault-resolver get with field name known to exist\n```\n\nArchive v2.4.1: 20 files, 47122 bytes\n\nFiles: manifest.json (630b), references/docker-diagnostics-without-cli.md (5596b), references/multiplexing-verification.md (3453b), references/remote-backup-cleanup.md (3321b), references/safety.md (4466b), references/security-audit-2026-07-clawhub.md (3469b), references/server-to-server-rsync.md (6280b), references/testing-pitfalls-2026-07-23.md (2834b), references/troubleshooting-field-notes.md (1537b), references/vault-backends.md (3355b), references/vault-key-format.md (2006b), references/vault-ssh-integration.md (4975b), release.json (489b), scripts/ssh-client.py (10886b), scripts/ssh-keys.sh (10506b), scripts/ssh-run-native.sh (9030b), scripts/ssh-run.sh (6697b), skill-card.md (2526b), SKILL.md (25789b), _meta.json (131b)\n\nFile v2.4.1:SKILL.md\n\n---\nname: ssh-executor\ndescription: Execute commands on remote hosts over SSH with structured discovery, pluggable credential backends, and safety guardrails. Supports SSH key-based and password-based auth with multiple credential sources (vault, env vars, direct files). Includes 6-step discovery protocol and standardized JSON output. Host-key verification is strict by default. Cross-platform: works on Hermes Agent, OpenClaw, Claude Code, Codex, and any LLM environment with bash + python3.\nmetadata:\n  platforms: [\"hermes\", \"openclaw\", \"claude-code\", \"codex\", \"generic\"]\n  os: [\"linux\", \"darwin\"]\n  requires: { bins: [\"bash\", \"python3\", \"base64\"], optional_bins: [\"ssh\", \"ssh-agent\", \"ssh-add\", \"ssh-keygen\", ] }\n---\n\n# SSH Executor\n\nExecute remote commands over SSH securely. Platform-agnostic — configure once, use anywhere.\n\n## Scope\n\n| Use for | Don't use for |\n|---------|---------------|\n| Connecting to Linux servers via alias, IP, user, port | Storing/displaying credentials in chat or logs |\n| Inspection commands (read-only) | Running destructive commands without user confirmation |\n| Maintenance with explicit `--confirm-dangerous` | Bypassing host-key verification (strict by default) |\n| Any credential backend: vault, env vars, key files, password prompts | Exposing private key contents anywhere |\n\n### Credential Sources (Tiered)\n\nThe skill works with **any** credential source. Configure what you have — no mandatory vault dependency.\n\n| Tier | Source | How | Example |\n|------|--------|-----|---------|\n| **1. Vault** | Any vault that outputs to stdout | `--vault-key <name>` + backend script | Bitwarden CLI, 1Password CLI, vault-resolver, HashiCorp Vault |\n\nThe default vault backend is **vault-resolver** (Hermes-native, Vaultwarden API). To use another vault:\n\n```bash\n# 1Password CLI\nexport VAULT_RESOLVER_BIN=\"op\"\n\n# Bitwarden CLI\nexport VAULT_RESOLVER_BIN=\"bw\"\n\n# Custom script (must accept JSON on stdin, output JSON on stdout)\nexport VAULT_RESOLVER_BIN=\"/path/to/your/vault-wrapper\"\n```\n\nSee `references/vault-backends.md` for setup guides for each platform.\n\n## Platform Setup\n\n### Hermes Agent / OpenClaw (native)\n```bash\n# vault-resolver is auto-detected at /opt/data/bin/vault-resolver\n# Store a key:\nssh-keys.sh store my-server ~/.ssh/id_rsa\n# Use:\nssh-run.sh --host my-server --vault-key my-server -- 'uptime'\n```\n\n### Claude Code / Codex / Generic LLM\n```bash\n# No vault — use env vars or key files:\nexport SSH_KEY=\"$(cat ~/.ssh/id_rsa)\"\nssh-run.sh --host my-server --user ubuntu --key ~/.ssh/id_rsa -- 'uptime'\n\n```\n\n### CI/CD / GitHub Actions\n```bash\nssh-run.sh --host ${{ secrets.SSH_HOST }} \\\n           --user ${{ secrets.SSH_USER }} \\\n           --key <(echo \"${{ secrets.SSH_KEY }}\") \\\n           -- 'deploy.sh'\n```\n\n## Server Discovery\n\nThe remote server inventory (aliases, IPs, ports, users) lives in the **wiki** at `/opt/data/wiki/entities/remote-servers.md`. When the user mentions an alias not in `~/.ssh/config`, check this file first — it contains the full table of all known servers.\n\n**Warning:** The actual `~/.ssh/config` may differ from what the wiki documents. In particular, the `User` directive is often **not** present for individual hosts. Always pass `--user <user>` explicitly in that case. The wiki contains the per-host user table.\n\nDo not use this skill to:\n- Store or transmit passwords in chat, logs, or memory files\n- Expose private key contents in chat, logs, or memory\n- Bypass host key verification without explicit user permission (default is strict)\n- Run destructive commands without user confirmation\n- Use `restore-to-file` unnecessarily — prefer ssh-agent\n\n## Recommended Flow\n\n1. Discover the target: alias, hostname, port, user — from SSH config or the request.\n2. Validate the host with the user before connecting.\n3. Decide risk level: read-only (safe) or mutating (requires confirmation).\n4. Execute with `scripts/ssh-run.sh` and the correct parameters.\n5. Report the result: success, exit code, stdout/stderr, next steps.\n\n## stdout/stderr Contract\n\n`ssh-run.sh` and its backends follow a strict output contract:\n\n| Stream | Content | Format |\n|--------|---------|--------|\n| **stdout** | Pure JSON with command result | `{\"success\": bool, \"exit_code\": int, \"stdout\": \"...\", \"stderr\": \"...\", ...}` |\n| **stderr** | Status messages (vault, warnings, progress) | Free text |\n\n**Rule:** stdout is **always** parseable by `json.load()`. It never contains vault messages, ssh warnings, or any non-JSON text. This enables pipelines like:\n\n```bash\nresult=$(ssh-run.sh --host <your-host> --user <user> --vault-key id-rsa -- 'df -h' 2>/dev/null)\necho \"$result\" | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d['stdout'])\"\n```\n\n## Security Rules\n\n- **Validate host with user.** Confirm hostname/IP before any connection, especially if inferred from config.\n- **Read-only first.** Start with inspection commands. Commands that modify state (systemctl, rm, apt, docker, firewall, etc.) require `--confirm-dangerous` and explicit user approval.\n- **Dangerous command heuristic is not exhaustive.** Always treat unknown or chained commands with caution.\n- **Private keys never go to chat, logs, stdout, or memory files.** The `ssh-run.sh` JSON output intentionally omits key paths.\n- **Vault credentials are resolved in RAM and passed to SSH via stdin pipe only.** The vault binary path is validated before execution.\n- **Key disk exposure is minimized but not eliminated in all paths.** Without `ssh-agent`, `ssh-keys.sh restore` writes decrypted key material to a temp file (`/tmp/ssh-vault-*` or `/dev/shm/ssh-vault-*`) that is cleaned up by trap handlers. `restore-to-file` writes to a caller-specified path. Interruption by `kill -9` prevents cleanup — prefer `ssh-agent` to avoid disk exposure entirely.\n- **Sensitive log access requires approval.** Reading `/var/log/auth.log`, `/var/log/secure`, or similar security logs exposes usernames, source IPs, and authentication patterns — treat as privileged telemetry. Always get explicit user approval and consider redacting PII (usernames, IPs) before sharing results.\n- **Prefer `/dev/shm` over `/tmp` for temporary key material.** `/dev/shm` is RAM-backed tmpfs and survives reboots less often than `/tmp`. The scripts use `/dev/shm` when available and fall back to a secure temp directory otherwise. Multiplexing sockets (not key material) in `/tmp` are acceptable.\n\n## MCP Permissions Declaration\n\nThis skill requires the following capabilities. Deploy with least-privilege toolset restrictions:\n\n| Capability | Scope | Justification |\n|-----------|-------|---------------|\n| `terminal` | Bash scripts (`ssh-run.sh`, `ssh-keys.sh`, `ssh-run-native.sh`) | SSH command execution |\n| `file` | Read `~/.ssh/config`, wiki server inventory, temp files | Configuration resolution |\n| `web` | vault-resolver API calls (localhost only) | Credential resolution |\n\n**Restricted:** Never grant `delegation` or `cronjob` to this skill — SSH execution must always be directly supervised by the user.\n\n## Structured Discovery Protocol\n\nFor new server investigations or infrastructure mapping, follow this discovery order — each step answers one question before moving on:\n\n| Step | Question | Typical commands |\n|------|----------|------------------|\n| 1. Identity | Who is this host? What role? | `hostname`, `cat /etc/os-release`, `uname -a` |\n| 2. Runtime | What is running? | `docker ps`, `systemctl list-units --type=service`, `ps aux`, `docker compose ps` |\n| 3. Origin | Where did it come from? Which repo/tag/volumes? | `docker inspect --format '{{.Config.Image}}' <container>`, `docker inspect --format '{{.Mounts}}' <container>`, `docker compose config` |\n| 4. Network | What is listening? | `ss -tlnp`, `docker port <container>`, `iptables -L -n` (with approval) |\n| 5. Data | Where is persistent data? | `df -h`, `lsblk`, `docker volume ls`, `mount` |\n| 6. Register | What do I know for sure vs. what am I inferring? | Structured report (see below) |\n\n**Narrow commands:** each command should answer **one** question. Avoid `docker inspect` without a field filter — prefer `--format '{{.Config.Image}}'` or `'{{.Mounts}}'`. Avoid `find /` or `grep -r /`.\n\nIf at any step you encounter a path with `.env`, `keystore/`, `secrets/`, or credential files — **do not read the contents**, only register its existence.\n\n## Expected Output\n\nWhen finishing a discovery or mapping, report in this format:\n\n```\nHost: <hostname>\nRole: <role description>\n\nRuntime:\n  - <container/service 1> → image:tag, compose: <path>\n  - <container/service 2> → image:tag, compose: <path>\n\nNetwork:\n  - <port>: <protocol> → <description>\n\nData:\n  - <mount>: <host path>\n\nConfirmed facts:\n  - ...\n\nInference / Assumptions:\n  - ... (mark explicitly)\n\nOpen questions:\n  - ...\n\nRequired approvals:\n  - ...\n```\n\nThis separates **evidence** from **interpretation** — any downstream operator can reproduce the facts and question the conclusions.\n\n## Backend behavior with `--vault-key`\n\n**As of 2026-07-22:** `--vault-key` **no longer forces the Python/Paramiko backend**. The native backend (`ssh-run-native.sh`) handles vault keys via `ssh-keys.sh restore` → `ssh-agent`. The Python/Paramiko backend (`ssh-client.py`) is used only as a fallback when `openssh-client` is unavailable.\n\n## Connection Multiplexing (ControlMaster)\n\nTo avoid audit log spam (`auth.log`) — where multiple SSH connections in a few seconds look like a brute-force attack — the native backend now uses **OpenSSH connection multiplexing**:\n\n```bash\n# First command: opens connection + authenticates (1 entry in auth.log)\nssh-run.sh --host <your-host> -- 'hostname'\n\n# Subsequent commands (within 180s): reuse socket (ZERO new entries)\nssh-run.sh --host <your-host> -- 'df -h'\nssh-run.sh --host <your-host> -- 'docker ps'\n```\n\n**Parameters:**\n- `--control-persist <seconds>` — how long the master socket stays alive after the last command (default: **180s**)\n- `--control-close` — close the master socket immediately after the command (for clean audit session termination)\n\n**Socket path:** `/tmp/ssh-mux-%r@%h:%p` (e.g. `/tmp/ssh-mux-root@10.0.0.5:22`)\n\n**Important:** Multiplexing only works when all commands to the same host use the **same** user and port. If alternating between `root@host` and `user@host`, each combination gets its own socket.\n\nActive multiplexing verification: `references/multiplexing-verification.md`.\n\n## Vault Integration\n\nThe skill supports pluggable vault backends via the `VAULT_RESOLVER_BIN` environment variable. The default backend is `vault-resolver` (Vaultwarden API, Hermes-native), but you can point it at any CLI that accepts JSON on stdin and returns JSON on stdout.\n\n### Quick setup per platform\n\n| Platform | Vault backend | Setup |\n|----------|--------------|-------|\n| **Hermes Agent** | vault-resolver (built-in) | Zero config — auto-detected |\n| **Bitwarden** | `bw` CLI | `export VAULT_RESOLVER_BIN=\"bw\"` |\n| **1Password** | `op` CLI | `export VAULT_RESOLVER_BIN=\"op\"` |\n| **HashiCorp Vault** | `vault` CLI | `export VAULT_RESOLVER_BIN=\"/path/to/vault-wrapper\"` |\n| **None** | Key files only | Skip `--vault-key`, use `--key` or env vars |\n\n### vault-resolver specifics (Hermes Agent)\n\nPath: `/opt/data/bin/vault-resolver` (hardcoded in scripts; override with `VAULT_RESOLVER_BIN`).\n\nKey storage convention:\n- SSH keys: item `ssh-<name>` with field `ssh_private_key` (base64)\n\nFor self-signed certificate environments: vault-resolver uses `SSL_CTX.check_hostname=False`. **Only safe on trusted local networks** (LAN, VPN, localhost).\n\nSee `references/vault-backends.md` for custom backend integration and API contract.\n\n### Usage with --vault-key\n\n```bash\n# ALWAYS pass --vault-key to connect with a vault key\nscripts/ssh-run.sh --host <your-host> --vault-key id-rsa -- 'command'\n\n# Does NOT work without --vault-key — IdentityFile from SSH config may not exist\n```\n\n### Fallback without vault-key (when key is not in vault)\n\nIf `--vault-key` fails because the SSH key was never stored in Vaultwarden (`Expecting value` error), use direct SSH with the local key on disk:\n\n```bash\nssh -o StrictHostKeyChecking=yes -i ~/.ssh/id_rsa <user>@<host> '<command>'\n```\n\nIf this is first contact with the host, verify its fingerprint manually before connecting. Add it to `~/.ssh/known_hosts` via `ssh-keyscan` or accept interactively. Never use `StrictHostKeyChecking=no` for unknown hosts.\n\nThis bypasses the vault-resolver dependency. The native backend with `ssh-agent` is the current default. Use this fallback **only for read-only commands** (df, ps, cat, ls). After confirming it works, consider storing the key in the vault:\n\n```bash\nssh-keys.sh store id-rsa ~/.ssh/id_rsa\n```\n\nThis way, `--vault-key` will work normally in future sessions.\n\n**Warning:** The `ssh-run.sh` wrapper flags commands with `2>/dev/null`, `|`, `&&`, `||` as dangerous (exit 99). When using direct SSH, these redirections work without blocking.\n\n### Known Pitfalls\n\n| Problem | Cause | Solution |\n|---------|-------|----------|\n| `Permission denied (publickey)` | `--vault-key` not passed; tries IdentityFile from SSH config | Add `--vault-key <name>` |\n| `'ssh-id-rsa' not found` | vault-resolver looks in `login.password`, but key is in `notes` | Use `ssh-keys.sh store` to recreate with custom field `ssh_private_key` (see `references/vault-key-format.md`) |\n| `Failed to resolve vault key <name>: Expecting value` | The SSH key **does not exist** in Vaultwarden — item `ssh-<name>` was never created, is empty, or has invalid format | Fall back to direct SSH with local key (see \"Fallback without vault-key\" below), then store the key in vault with `ssh-keys.sh store <name> ~/.ssh/id_rsa` |\n| `No module named 'paramiko'` | Python/Paramiko backend (fallback, used only when `openssh-client` is unavailable on the system) but paramiko not installed | `uv pip install --target ~/.openclaw/workspace/.ssh-pylib paramiko` |\n| `timed out` (SSH connection) | Wrong port or hostname — Python backend wasn't resolving SSH config | Now resolves HostName/Port/User from `~/.ssh/config` automatically |\n| `authentication failed` | (a) SSH key was never deployed to server's `authorized_keys` — Paramiko rejects during key exchange; (b) vault-resolver returned key from wrong field (notes vs field) | (a) Fall back to direct SSH with local key (see \"Fallback without vault-key\" below) and confirm the key works; then optionally store with `ssh-keys.sh store <name> <path>`; (b) Use `ssh-keys.sh store` to recreate in correct format |\n| `ssh-keys.sh` timeout (~30s) | vault-resolver authentication via bw CLI was slow (~18s) | Direct API rewrite reduces to ~2s with session cache |\n| `PermissionError: /tmp/.bw_hermes/api_session.json` | vault-resolver cache created by different UID (e.g. 1000 vs 10000) | Set `BITWARDENCLI_APPDATA_DIR` to a writable directory, e.g. `BITWARDENCLI_APPDATA_DIR=/opt/data/tmp/bw-cache` before calling vault-resolver or ssh-run.sh with --vault-key |\n| `cat` with redirections or short pipes blocked as \"dangerous\" (exit 99) | `ssh-run.sh` heuristic detects `2>/dev/null`, pipes `|` or `&&`/`||` as potentially dangerous, even for read-only commands | Simplify the command: remove redirections, split into separate commands, or run with `ssh -i ~/.ssh/id_rsa` directly without the wrapper |\n| `find` command with pipe blocked as \"dangerous\" (exit 99) | False positive from heuristic: `find ... | while read d; do ts=$(stat -c %Y \"$d\"); ...` contains `stat` + pipes that trigger the dangerous command detector, even though it's read-only | Simplify the command to avoid pipes with `stat`: (a) use `ls -1` + separate loop, (b) run in two SSH calls, or (c) use `--confirm-dangerous` **only when the command is provably read-only** |\n| `ControlSocket /tmp/ssh-mux-... already exists` | Master socket from a previous session is still active (within ControlPersist) | Normal and expected — means multiplexing is working. To force a new socket, use `--control-close` on the previous command or wait for TTL to expire |\n| **`Host key verification failed` despite correct known_hosts** | SSH config has `UserKnownHostsFile /dev/null` which discards all host keys | The script now forces `UserKnownHostsFile` when `StrictHostKeyChecking=yes`. Ensure host keys are in `${HOME}/.ssh/known_hosts` |\n| `ControlPersist` doesn't work with different hosts even if same IP | `ControlPath` uses `%h` (hostname), not IP | Use the same alias/hostname in all calls. If you need to switch between alias and IP, standardize on the `~/.ssh/config` alias |\n| `ssh-run.sh` JSON output contains vault messages (\"Retrieving SSH key...\") | Old version of `ssh-keys.sh` (< 2026-07-22) wrote info messages to stdout | Update to the current version: all `cmd_restore()` messages now go to stderr. Stdout contains only JSON |\n| `--vault-key` loads key but falls back to Python backend (no multiplexing) | `ssh-agent` is not running. `ssh-keys.sh restore` detects the absence and creates a temp file for the Python backend, which lacks ControlMaster | Start `ssh-agent` first: `eval \"$(ssh-agent -s)\"`. Without an active agent, each `--vault-key` generates a new TCP connection = auth.log spam |\n| `Permission denied (publickey)` with wrong resolved_user | `~/.ssh/config` has no `User` directive for the host, and the alias doesn't specify a user. SSH uses the local system user | Pass `--user <user>` explicitly: `ssh-run.sh --host <your-host> --user <user> --vault-key <key-name> -- 'command'`. Or add `User <user>` to the `Host` entry in `~/.ssh/config` |\n| `Host key verification failed` (exit 255) on first contact | Default is strict (`StrictHostKeyChecking=yes`). Host not in `~/.ssh/known_hosts`. | Manually verify the host fingerprint, then run: `ssh-keyscan <host> >> ~/.ssh/known_hosts`. Do NOT use `--host-key-checking no` as a shortcut. |\n| `paramiko.ssh_exception.SSHException: Server ... not found in known_hosts` (Python backend) | Python backend uses `RejectPolicy` by default — same strict behaviour as native | Use native backend if possible (has multiplexing). If Python is required, verify host fingerprint and add to `~/.ssh/known_hosts`, or use `--host-key-checking no` only with explicit user approval. |\n| `restore-to-file` leaves key on disk after crash | `ssh-keys.sh restore-to-file` writes decrypted key to a caller-specified path. Interruption (kill -9) prevents cleanup. | Prefer `ssh-keys.sh restore` (loads into ssh-agent, no disk). Use `restore-to-file` only as last resort. Verify cleanup after use. |\n| `shred: command not found` (macOS) during key cleanup | macOS does not ship `shred`. The fallback `rm -f` is used but does not securely overwrite blocks. | Acceptable on macOS (APFS encryption at rest mitigates). On Linux, ensure `coreutils` is installed. |\n| `restore-to-file` fails with \"Refusing to write private key to system directory\" | Path validation rejects system dirs: `/etc`, `/boot`, `/sys`, `/proc`, `/dev`, `/run` | Use a path under `/tmp/` or your home directory. |\n| `restore-to-file` warns about non-tmp path | Output path not under `/tmp` or `/dev/shm` — key will persist across reboots | Use `/tmp/` for temporary key material. |\n| `⚠️ SECURITY WARNING: Host-key checking disabled` in stderr | `--host-key-checking no` was passed — connection is MITM-vulnerable | Confirm with the user that they understand and accept the risk. Prefer adding the host key to `known_hosts` instead. |\n| `Host key verification failed` despite host key in known_hosts | `~/.ssh/config` has `UserKnownHostsFile /dev/null` — SSH ignores known hosts. Strict checking has no trust store. | Native backend now forces `-o UserKnownHostsFile=${HOME}/.ssh/known_hosts` when strict. Grep config for `UserKnownHostsFile` directives pointing to `/dev/null`. See `references/testing-pitfalls-2026-07-23.md`. |\n| `ssh-keys.sh restore` exits 1 but prints \"✓ loaded\" (ghost failure) | `set -e` + trap with `shred -u` on already-deleted temp file. EXIT trap fires after success path deleted file. | Fixed: trap uses `rm -f`. Explicit `shred -u` runs in code paths; trap is safety net. See `references/testing-pitfalls-2026-07-23.md`. |\n| Temp key survives `kill -9` | `trap` cannot catch SIGKILL. If the process is hard-killed during `ssh-keys.sh restore`, the temp file at `/dev/shm/ssh-vault-*` (or `/tmp/ssh-vault-*` as fallback) remains. | Use `ssh-keys.sh cleanup` to list stale keys and `ssh-keys.sh cleanup --force` to shred them. The files are also cleaned on next normal invocation when the same key name is restored. |\n\n### Ensuring vault-resolver is accessible\n\nssh-executor scripts use `/opt/data/bin/vault-resolver` (hardcoded path).\n\n```bash\nexport PATH=\"/opt/data/bin:$PATH\"\n```\n\n## Server-to-Server rsync (without ForwardAgent)\n\n> **⚠️ DEPRECATED PATTERN — see `references/server-to-server-rsync.md` for security warning and preferred alternatives.**\n\nWhen you need to copy files **between two remote servers** and there is no\nssh-agent running locally (making ForwardAgent impossible), prefer the SSH\ntunneled variant (ForwardAgent) or pull-via-jump approach. Copying a private\nkey to a remote server is a last resort — use only with a dedicated\nsingle-purpose key and explicit user approval.\n\n## Bundled Resources\n\n### `scripts/ssh-run.sh`\nMain remote execution script. Automatically selects backend:\n- **openssh-client native** when `ssh`/`ssh-agent`/`ssh-add` are available (includes vault-key via `ssh-agent`)\n- **Python + Paramiko** as fallback (works without SSH binaries on the system)\n- **Connection multiplexing** enabled by default (ControlMaster, 180s) to avoid audit log spam\n\n```bash\nscripts/ssh-run.sh --host <host> [--user <user>] [--port <port>] \\\n                   [--key <path>] [--vault-key <name>] \\\n                   [--timeout <sec>] [--host-key-checking <yes|no>] \\\n                   [--confirm-dangerous] \\\n                   [--control-persist <sec>] [--control-close] \\\n                         -- '<remote command>'\n\nscripts/ssh-run.sh --list-aliases              # list aliases from ~/.ssh/config\n```\n\n`--vault-key <name>` retrieves the SSH key from Vaultwarden (item `ssh-<name>`) and loads it into `ssh-agent`. With an active agent, the key stays in memory. Without `ssh-agent`, a temp file in RAM-backed `/dev/shm` is used as fallback (cleaned by trap handlers; see security caveats).\n`--control-persist <sec>` how long the multiplexing socket stays alive (default: 180).\n`--control-close` closes the master socket immediately after the command.\n\n### `scripts/ssh-keys.sh`\nManages SSH keys in Vaultwarden.\n\n```bash\nscripts/ssh-keys.sh store <name> <path>          # store local key in vault\nscripts/ssh-keys.sh restore <name>               # load from vault into agent (or temp file)\nscripts/ssh-keys.sh restore-to-file <name> <out> # write from vault to file\nscripts/ssh-keys.sh cleanup                         # list stale temp key files\nscripts/ssh-keys.sh cleanup --force                 # shred stale temp key files\nscripts/ssh-keys.sh agent-status                 # show keys loaded in agent\n```\n\n### `references/multiplexing-verification.md`\nVerification procedure to confirm ControlMaster multiplexing is active: 6-step checklist (socket timestamp, mux PID, ss, auth.log), failure signals, and quick diagnostic command.\n\n### `references/vault-backends.md`\nAPI contract and setup guides for pluggable vault backends: vault-resolver (Hermes), Bitwarden CLI, 1Password CLI, HashiCorp Vault wrapper, and no-vault mode.\n\n### `references/vault-ssh-integration.md`\nDetailed architecture of how SSH keys are stored in Vaultwarden and loaded into memory. Read when you need to understand the security model or full flow.\n\n### `references/docker-diagnostics-without-cli.md`\n\nTechniques for diagnosing Docker containers remotely **without** access to the\nDocker CLI (user without docker group, without sudo NOPASSWD). Lists 10 command\ntechniques (`cgroup`, `/proc/PID`, `ip neigh`, `ss`, `/dev/tcp`, docker-compose,\nconfig files) and a real-world MariaDB case with `network_mode: host` that wasn't\nlistening on any port.\n### `references/safety.md`\nDetailed security rules for remote execution. Read when in doubt about what is considered destructive or how to handle sensitive hosts.\n\n### `references/security-audit-2026-07-clawhub.md`\nClawHub SkillSpector security audit results (47 findings). Documents all high-confidence vulnerabilities found and how they were fixed in v2.2.0. Read when maintaining or extending the skill to avoid reintroducing known issues: host-key bypass, credential mismanagement, key exfiltration, undeclared MCP capabilities.\n\n### `references/remote-backup-cleanup.md`\nRemote backup cleanup pattern with `find -mtime +N -delete`. Covers the permission model (write access to directory vs. file ownership), solutions for `Permission denied`, and diagnostic commands with `namei`. Refer to when deleting old files in remote backup directories.\n\n## Example Requests That Should Trigger This Skill\n\nThese phrases require **explicit user invocation** — the skill should NOT auto-trigger from casual conversation about servers.\n\n- \"ssh-executor: check uptime on the production server\"\n- \"Run ssh-run.sh to inspect docker ps on the app server\"\n- \"Use the SSH skill to store my id_rsa key as 'prod-key'\"\n- \"Access the server via ssh-executor and check journalctl\"\n- \"ssh-executor: systemctl restart nginx on web-server (with confirmation)\"\n- \"Sync backups from app-server to backup-server using ssh-executor rsync pattern\"\n\nThe skill triggers on phrases that explicitly reference **ssh-executor** or the **ssh-run.sh / ssh-keys.sh** scripts. Casual mentions of \"server\", \"deploy\", or \"SSH\" alone should NOT activate this skill.\n\nFile v2.4.1:_meta.json\n\n{\n  \"ownerId\": \"kn7e7q1wce41djeky0k2zscsw184xh21\",\n  \"slug\": \"ssh-executor\",\n  \"version\": \"2.4.1\",\n  \"publishedAt\": 1784828799021\n}\n\nFile v2.4.1:references/docker-diagnostics-without-cli.md\n\n# Docker Diagnostics Without Docker CLI\n\nWhen the remote SSH user is **not in the docker group** and **has no sudo\nNOPASSWD** — but you still need to diagnose containers, networks, and services.\n\n## Techniques\n\n### 1. List active containers via cgroup\n\n```bash\nls /sys/fs/cgroup/system.slice/docker-*.scope 2>/dev/null | while read f; do\n  id=$(echo \"$f\" | grep -oP \"docker-\\K[a-f0-9]{12}\")\n  echo \"Container ID: $id\"\ndone\n```\n\n⚠️ Shows ALL active containers (not just running — any process in the\ncgroup). Useful as a starting point.\n\n### 2. Identify processes inside a container\n\n```bash\n# PIDs in the container\ncat /sys/fs/cgroup/system.slice/docker-<FULL_ID>.scope/cgroup.procs\n\n# What each PID executes\nfor pid in $(cat /sys/fs/cgroup/system.slice/docker-<ID>.scope/cgroup.procs); do\n  cmd=$(cat /proc/$pid/cmdline 2>/dev/null | tr \"\\0\" \" \" | head -c 200)\n  echo \"PID $pid: $cmd\"\ndone\n```\n\n### 3. Determine the container's network_mode\n\nCompare the process network namespace with the host's:\n\n```bash\nhost_ns=$(readlink /proc/1/ns/net)\ncontainer_ns=$(readlink /proc/<PID>/ns/net)\nif [ \"$host_ns\" = \"$container_ns\" ]; then\n  echo \"network_mode: host\"\nelse\n  echo \"network_mode: bridge (or other)\"\nfi\n```\n\n### 4. Find container IPs via bridge ARP\n\nKnowing which bridge is active (via `ip addr show`):\n\n```bash\n# List active bridges\nip addr show | grep -E \"^[0-9]+: br-|^[0-9]+: docker\" | grep UP\n\n# Show ARP neighbors (active IPs)\nip neigh show dev br-<ID>\n```\n\nExample output:\n```\n172.18.0.5 lladdr de:9a:bb:2b:2a:32 REACHABLE\n172.18.0.7 lladdr 66:1d:5b:2c:c2:08 STALE\n```\n\n**REACHABLE/STALE** IPs = active containers. **FAILED** = IP does not exist.\n\n### 5. Discover which process listens on which port\n\n```bash\nss -tlnp    # TCP listening, with PID\nss -ulnp    # UDP\n```\n\n⚠️ Without root, `ss -p` does not show the process (empty column). Use the port\nas a clue and cross-reference with `/proc/PID/cmdline`.\n\n### 6. Test TCP connectivity without tools\n\n```bash\ntimeout 2 bash -c \"echo > /dev/tcp/<IP>/<PORT>\" 2>/dev/null && echo \"OPEN\"\n```\n\nUseful for quickly scanning ports on a bridge or host — **only on networks you own or have explicit authorization to probe**:\n\n```bash\n# ⚠️ Network scanning — verify authorization before running\nfor ip in 172.18.0.{1..10}; do\n  timeout 1 bash -c \"echo > /dev/tcp/$ip/3306\" 2>/dev/null && echo \"$ip:3306 OK\"\ndone\n```\n\n### 7. Read container configuration via docker-compose\n\nNot every container was started with compose, but when it was:\n\n```bash\ncat /path/docker-compose.yml\n```\n\nPay special attention to:\n- `network_mode:` (host vs bridge)\n- `networks:` → `driver:` (host = shares host IP)\n- `ports:` (mapping)\n- `extra_hosts:` (hostname resolution)\n- `environment:` / `env_file:` (configuration variables)\n\n### 8. Check internal configuration files\n\nFor services like MySQL/MariaDB, even without container access:\n\n```bash\n# Process has config args in cmdline\ncat /proc/<PID>/cmdline | tr \"\\0\" \" \"\n\n# Check default config (host filesystem)\ncat /etc/mysql/mysql.conf.d/mysqld.cnf  # Host MySQL\n# Container may have a DIFFERENT config\n\n# For MariaDB in network_mode host:\n# The container default bind-address is 0.0.0.0\n# But the docker run command may override with --bind-address=127.0.0.1\n```\n\n### 9. Check ports on the host (outside container)\n\n```bash\n# Ports listening on IPv4\nss -tlnp -4\n```\n\nQuick common ports table:\n\n| Port | Service | Typical container |\n|-------|---------|-----------|\n| 3306 | MySQL/MariaDB | mysql-db, web-db |\n| 80 | HTTP | nginx, apache |\n| 443 | HTTPS | nginx (via reverse proxy) |\n| 11211 | Memcached | memcached |\n| 10051 | Zabbix trapper | zabbix-server |\n\n### 10. Check MariaDB/MySQL datadir\n\nWhen the container mounts a volume:\n\n```bash\nls /var/lib/<project>/mariadb/    # or mysql/\n# List databases:\nls /var/lib/<project>/mariadb/ | grep -v \"^#\" | grep -v \"^aria\\|^ib_\\|^mysql\\|^performance\\|^sys\"\n```\n\n## Real Case: MariaDB container with network_mode host\n\nA real-world example: a `mysql-db` container (mariadb:10.5) was configured with\n`network_mode: host` and mysqld was running (active PID), but it was **not\nlistening on any port 3306** — neither TCP nor Unix socket.\n\nDiagnosis performed:\n1. `cat /proc/<PID>/cmdline` → confirmed mysqld with MariaDB args\n2. `readlink /proc/<PID>/ns/net == readlink /proc/1/ns/net` → network_mode host\n3. `ss -tlnp` → **zero** port 3306 on any IP\n4. `cat /etc/mysql/mysql.conf.d/mysqld.cnf` → bind-address = 127.0.0.1 on host\n   (but this is the host config, not the container's)\n5. `ls /var/lib/<project>/mariadb/` → datadir present with databases\n6. Bridge 172.18.0.0/16 active (with containers) vs 172.19.0.0/16 linkdown\n\n**Conclusion:** The MariaDB inside the container did not complete initialization\ncorrectly or the default `bind-address` (0.0.0.0 in official mariadb) was\noverridden. Requires `docker logs <container>` (with docker access) for\nfinal diagnosis.\n\n## Pitfalls\n\n| Problem | Cause | Solution |\n|----------|-------|---------|\n| `ls /proc/PID/fd/` empty | PID runs as different UID (e.g. 999 = mysql) | Try `sudo ls` or use other techniques |\n| `nsenter` access denied | No CAP_SYS_ADMIN permission | Do not use nsenter without sudo |\n| `ip neigh` shows FAILED | Container with network_mode host (no dedicated IP) | Container shares host IP; look for port on host |\n| Bridge linkdown but IP configured | Docker network without containers (created but unused) | Check which bridge has REACHABLE/STALE traffic |\n| `docker logs` unavailable | No docker CLI access | Only option: `journalctl` or process logs in datadir |\n\nFile v2.4.1:references/multiplexing-verification.md\n\n# Multiplexing Verification\n\nProcedure to confirm that ControlMaster multiplexing is working correctly.\n\n## Quick Checklist (6 steps)\n\n### 1. Does the local socket exist?\n\n```bash\nls -la /tmp/ssh-mux-<user>@<hostname>:<port>\n# Ex: /tmp/ssh-mux-root@10.0.0.5:22\n```\n\n**Expected:** UNIX socket (`srw-------`) with the first connection's timestamp.\n\n### 2. Does the socket timestamp stay unchanged across subsequent commands?\n\n```bash\nstat -c '%Y' /tmp/ssh-mux-<user>@<host>:<port>  # before\nssh-run.sh --host <host> --user <user> --vault-key <key> -- 'date'\nstat -c '%Y' /tmp/ssh-mux-<user>@<host>:<port>  # after — same value!\n```\n\n**Expected:** same epoch timestamp before and after.\n\n### 3. Only 1 mux process for the host?\n\n```bash\nps aux | grep \"[s]sh.*mux.*<host>\"\n```\n\n**Expected:** exactly 1 process `ssh: /tmp/ssh-mux-... [mux]`.\n\n### 4. Are TCP connections on the server consistent?\n\n```bash\nssh-run.sh --host <host> --user <user> --vault-key id-rsa -- 'ss -tn sport = :22'\n```\n\n**Expected:** the number of ESTABLISHED connections to the Hermes IP does not increase with each command.\n\n### 5. Does auth.log have only 1 Accepted per window?\n\n```bash\nssh-run.sh --host <host> --user <user> --vault-key <key> \\\n           --sudo --sudo-pass-vault <name> \\\n           -- 'sudo grep \"sshd.*Accepted.*<user>\" /var/log/auth.log | tail -5'\n```\n\n**Expected:** only 1 `Accepted publickey` entry for the entire batch of commands within the ControlPersist window.\n\n### 6. Is the JSON output clean (no vault pollution)?\n\n```bash\nresult=$(ssh-run.sh --host <host> --user <user> --vault-key id-rsa -- 'hostname' 2>/dev/null)\necho \"$result\" | python3 -c \"import sys,json; d=json.load(sys.stdin); print('OK:', d['stdout'].strip())\"\n```\n\n**Expected:** successful parse, no \"Retrieving SSH key...\" messages in stdout.\n\n## Signs that multiplexing is NOT active\n\n| Symptom | Likely cause |\n|---------|-------------|\n| Socket does not exist after command | `ssh-agent` is not running → Python backend (no ControlMaster) |\n| Multiple sockets with different timestamps | Different `--user` across calls → each user@host combination creates its own socket |\n| Socket exists but timestamp changes per command | `ControlPersist` expired between calls (>180s) |\n| `Permission denied (publickey)` | Wrong `--user` (local user instead of remote) |\n\n## Quick diagnostic command\n\n```bash\n# Close old socket, test 3 commands, verify\nssh -o ControlPath=/tmp/ssh-mux-<user>@%h:%p -O exit <user>@<host> 2>/dev/null || true\n\nTS1=$(bash ssh-run.sh --host <host> --user <user> --vault-key <key> --control-persist 180 -- 'hostname' 2>/dev/null | python3 -c \"import sys,json; print(json.load(sys.stdin)['stdout'].strip())\")\nS1=$(stat -c '%Y' /tmp/ssh-mux-<user>@<host>:<port> 2>/dev/null)\n\nTS2=$(bash ssh-run.sh --host <host> --user <user> --vault-key <key> -- 'date' 2>/dev/null | python3 -c \"import sys,json; print(json.load(sys.stdin)['stdout'].strip())\")\nS2=$(stat -c '%Y' /tmp/ssh-mux-<user>@<host>:<port> 2>/dev/null)\n\nTS3=$(bash ssh-run.sh --host <host> --user <user> --vault-key <key> -- 'whoami' 2>/dev/null | python3 -c \"import sys,json; print(json.load(sys.stdin)['stdout'].strip())\")\nS3=$(stat -c '%Y' /tmp/ssh-mux-<user>@<host>:<port> 2>/dev/null)\n\necho \"Socket epochs: $S1 $S2 $S3\"\necho \"Multiplexing: $([ \"$S1\" == \"$S2\" ] && [ \"$S2\" == \"$S3\" ] && echo '✅ ACTIVE' || echo '❌ FAILED')\"\necho \"Mux PID: $(ps aux | grep '[s]sh.*mux.*<host>' | awk '{print $2}')\"\n```\n\nFile v2.4.1:references/remote-backup-cleanup.md\n\n# Remote Backup Cleanup via SSH\n\n> **⚠️ DESTRUCTIVE OPERATIONS:** All commands in this document delete data permanently. \n> - **Always dry-run first** with `-ls` or `-printf` to verify what will be deleted\n> - **Require explicit user confirmation** before running any command with `-delete`\n> - See `safety.md` for the full confirmation policy\n\nClean up old backups on remote servers using `find -mtime +N -delete`.\n\n## Basic pattern\n\n```bash\n    --confirm-dangerous -- 'find /srv/backup -type f -mtime +15 -delete'\n\n# Direct SSH (manual, no guardrails):\nssh <user>@<host> 'find /srv/backup -type f -mtime +15 -delete'\n```\n\nThe `-delete` flag only works after `-type f` (prevents accidentally deleting directories).\n\n## Permission model — common pitfall\n\nDeleting a file does **not** require write permission on the **file** — it requires write permission (`w`) on the **directory** that contains it.\n\n### Quick diagnosis\n\n```bash\n# View complete hierarchy permissions\nnamei -l /srv/backup/2026-06-13/backup_file.tar.gz\n```\n\n### Typical scenario\n\n```\ndrwxr-xr-x root backup  srv/backup/        # backup group has r-x, missing w\ndrwxr-xr-x root backup  2026-06-13/        # backup group has r-x, missing w\n-rw-r--r-- root backup  file.tar.gz         # group read-only — irrelevant\n```\n\nEven if the **remote user** is in the `backup` group (via `groups` or `id`) and the **files** are `root:backup`, deletion fails with `Permission denied` if the directory lacks `w` for the group.\n\n### Solutions (in order of preference)\n\n| Approach | Requirement | Risks |\n|-----------|-----------|--------|\n| **sudo** | Remote user's sudo password | Simplest, but needs interaction |\n| **chmod g+w on directories** | Directory owner or sudo | Permission stays open; requires directory owner |\n| **Cron job as root** | Backup service runs as root | Ideal for automated routine |\n| **ACL** | Filesystem with ACL support | `setfacl -m g:backup:rwx /srv/backup` |\n\n### Real case example\n\nA common scenario encountered in production:\n\n- Server with backup directories owned by `root:backup`, permissions `drwxr-xr-x`\n- The remote user is in the `backup` group but lacks write permission on directories\n- Backup files are owned by `root:root` (or `root:backup`)\n- Deletion fails without sudo because directories lack `w` for the `backup` group\n\nThis is not a file permission issue — it's a **directory write permission** issue.\nThe solution is sudo, `chmod g+w` on directories, ACLs, or a cron job as root.\n\n## Useful commands\n\n```bash\n# Dry-run: list files older than 15 days with size\nssh <host> 'find /srv/backup /archive/backup -type f -mtime +15 -ls'\n\n# Count and total size\nssh <host> 'find /srv/backup /archive/backup -type f -mtime +15 -printf \"%s\\n\" | awk \"{sum+=\\$1} END {printf \\\"Files: %d, Size: %.2f GB\\n\\\", NR, sum/1073741824}\"'\n\n# Delete (runs as user, fails without write permission on directories)\nssh <host> 'find /srv/backup /archive/backup -type f -mtime +15 -delete'\n\n# Delete with sudo (requires password)\nssh <host> 'sudo find /srv/backup /archive/backup -type f -mtime +15 -delete'\n```\n\n## Note on `-delete`\n\n`find ... -delete` implies `-depth`, so it processes subdirectories before parents. Safe for files. For leftover empty directories, a second `find ... -type d -empty -delete` cleans up the remains.\n\nFile v2.4.1:references/safety.md\n\n# SSH Executor Safety Notes\n\n## Default posture\n\n- **Read-only first.** Start with inspection commands (`hostname`, `uptime`, `df -h`, `journalctl -n 100`, `docker ps`).\n- **Explicit confirmation before any mutation.** State-changing commands require the user to see and approve the exact command before `--confirm-dangerous` is passed.\n- **Least-privilege SSH accounts.** Use a dedicated read-only or low-privilege SSH account for inspection when available. Only escalate to a privileged account for authorized mutation.\n- The script's dangerous-command heuristic (`is_dangerous_command`) is a **best-effort pattern check**, not a guarantee. It can produce both false positives and (more critically) false negatives. An empty check does not mean the command is safe.\n- Prefer SSH aliases and existing `~/.ssh/config` entries.\n- Prefer private keys over passwords.\n- Keep timeouts short unless the user clearly expects a long-running command.\n- Let ssh config resolve host, user, port, and identity file when an alias already exists.\n\n## Host-key policy\n\n- **Default is strict: `StrictHostKeyChecking=yes`.** Unknown hosts are rejected — the user must explicitly verify and accept the host key before connecting.\n- `yes`: **default and only safe option** for most deployments. Requires the host key to be in `~/.ssh/known_hosts`.\n- `no`: **do not use** unless the user explicitly understands and accepts the man-in-the-middle risk. Required only for ephemeral environments where host keys change frequently.\n- Existing ssh config policy wins if you do not pass `--host-key-checking`, but the script's default is `yes` (strict) when no config entry exists.\n- **`accept-new` has been removed.** It was a security antipattern — auto-trusting unknown hosts on first contact without fingerprint verification.\n\n## Commands that always need confirmation\n\nAsk the user before running any command that:\n- modifies files, permissions, or ownership (`rm`, `mv`, `chmod`, `chown`, `tee`, `dd`, `truncate`, `sed -i`)\n- restarts, stops, or disables services (`systemctl restart|stop|disable`, `service`, `initctl`)\n- installs, removes, or upgrades packages (`apt`, `apt-get`, `dnf`, `yum`, `apk`, `pacman`, `dpkg`, `rpm`)\n- reboots or shuts down the host (`reboot`, `shutdown`, `poweroff`)\n- deletes, rotates, or truncates data (`truncate`, `dd`, logrotate actions)\n- changes containers, databases, firewalls, or network state (`docker rm|down|kill`, `kubectl delete`, `iptables`, `ufw`, `firewall-cmd`, `ip link set`, `ip addr add|del`, `nmcli`)\n- writes to disk or pipes output to a file (`>`, `>>`, `| tee`, `dd`)\n- executes code on the remote host that was not explicitly reviewed (`curl | bash`, `wget -O- | sh`, `eval`, `source`)\n\n**When in doubt, treat the command as dangerous and ask for confirmation.**\n\nThe script returns a guardrail error (exit code 99) for commands matching the heuristic unless `--confirm-dangerous` is present.\n\n## Credential hygiene\n\n- **Use dedicated least-privilege SSH keys** for remote inspection. Create a separate key/alias with read-only permissions instead of reusing a full-access key.\n- **Do not paste private keys or passwords into chat** under any circumstance.\n- The script's JSON output intentionally **omits key paths, SSH config paths, and resolved identity file paths** to avoid leaking credential metadata to logs, chat, or memory files.\n- If an SSH alias resolves to a privileged account by default, configure a separate alias for inspection or explicitly pass `--user` with a low-privilege user.\n- **Stale temp key cleanup:** If `ssh-keys.sh restore` is interrupted by `kill -9`, temp key files at `/dev/shm/ssh-vault-*` or `/tmp/ssh-vault-*` may persist. Run `ssh-keys.sh cleanup` to list them and `ssh-keys.sh cleanup --force` to shred and remove.\n\n## stdout/stderr## stdout/stderr contract\n\nAll scripts follow a strict output contract for security and parseability:\n\n| Stream | Content | Format |\n|--------|---------|--------|\n| **stdout** | Pure JSON result | `{\"success\": bool, \"exit_code\": int, \"stdout\": \"...\", \"stderr\": \"...\", ...}` |\n| **stderr** | Status messages (vault, warnings, progress) | Free text |\n\n- **Never** parse stdout without going through JSON — vault status messages on stdout indicate an outdated `ssh-keys.sh`.\n- The `\"command\"` field in JSON always shows the **original** user command, even when wrapped with sudo/base64 internally.\n- The `\"sudo\": true` field indicates privileged execution.\n\nFile v2.4.1:references/security-audit-2026-07-clawhub.md\n\n# ClawHub Security Audit — July 2026\n\n**Auditor:** SkillSpector by NVIDIA\n**Scope:** ssh-executor skill v2.1.0\n**Total findings:** 47 (25 detailed, 22 hidden)\n\n## Findings Fixed in v2.2.0 (High Severity)\n\n| # | Category | Confidence | Finding | Fix |\n|---|----------|-----------|---------|-----|\n| 1 | Tool Poisoning | 99% | Documented behavior contradicts safety model (passwords, key restore, sudo, auto-accept host keys) | Description updated to match actual capabilities; host-key default → strict |\n| 2 | Credential Mismanagement | 99% | Header forbids passwords but docs instruct storage | Header clarified: \"passwords for automation may be stored in Vaultwarden with explicit approval\" |\n| 3 | Key Exfiltration | 99% | Rsync workflow copies private key to remote server | server-to-server-rsync.md deprecated; ForwardAgent preferred |\n| 4 | Host-Key Bypass | 99% | AutoAddPolicy in Python, accept-new in native | Default RejectPolicy (Python), StrictHostKeyChecking=yes (native), accept-new removed |\n| 5 | Destructive Commands | 94% | sudo find ... -delete without confirmation | Warning banner in remote-backup-cleanup.md |\n| 6 | Undeclared MCP | 92% | Shell, file, env-var access undeclared | MCP Permissions Declaration section added |\n\n## Findings Fixed in v2.2.1 (Medium Severity)\n\n| # | Category | Confidence | Finding | Fix |\n|---|----------|-----------|---------|-----|\n| 7 | Temp Key Cleanup | 93-95% | trap EXIT only, kill -9 bypasses | trap EXIT INT TERM HUP + shred -u |\n| 8 | restore-to-file Path | 92% | Writes to any caller-specified path | Path validation: rejects /etc, /boot, /sys, /proc, /dev, /run; warns non-tmp |\n| 9 | Missing User Warning | — | No warning on --host-key-checking no | Explicit stderr MITM warning |\n| 10 | Pass-through Bug | — | --host-key-checking ignored by Python backend | ssh-run.sh now captures and passes to ssh-client.py |\n| 11 | SSL/TLS | — | check_hostname=False without caveat | \"Only safe on trusted local networks\" |\n\n## Confirmed Safe (No Action Needed)\n\n- vault-resolver uses direct API with session cache — not vulnerable to bw CLI dependency issues\n- ssh-run.sh JSON output omits key paths, SSH config paths, resolved identity files\n- --sudo auto-enables --confirm-dangerous, no separate flag needed\n- Password-based sudo pipes via stdin, never appears in ps aux\n\n## Remaining Low-Severity / Informational\n\n47 total findings minus 11 fixed = 36 remaining. The remaining are either:\n- Low-severity informational (e.g., \"skill enables shell execution\" — by design)\n- False positives (e.g., \"SSH key in vault is stored\" — Vaultwarden is AES-256 encrypted at rest)\n- Duplicates of the 11 already fixed\n\n## Lessons for Skill Authors\n\n1. **Default-deny for host keys.** Never auto-accept in any backend.\n2. **Description must match implementation.** If the skill supports password auth, say so honestly — don't pretend it's key-only.\n3. **Trap all signals, not just EXIT.** INT, TERM, and HUP are common; kill -9 remains a known gap.\n4. **Validate caller paths.** Any restore-to-file action should reject system directories.\n5. **Declare MCP permissions.** Even if the platform doesn't enforce them yet, document what capabilities the skill needs.\n6. **Warn explicitly on unsafe choices.** --host-key-checking no should produce a visible stderr warning.\n7. **Deprecate, don't hide.** The key-copy rsync pattern is still documented but clearly marked as deprecated with preferred alternatives.\n\nFile v2.4.1:references/server-to-server-rsync.md\n\n# Server-to-Server rsync via Temporary Key\n\n> **⚠️ SECURITY WARNING — DEPRECATED PATTERN**\n>\n> Copying a private SSH key to a remote server (even temporarily) is a **credential staging vulnerability**. If the remote server is compromised, the attacker gains access to all servers that key can authenticate to.\n>\n> **Preferred alternatives (in order):**\n> 1. **SSH tunneled with ForwardAgent** (section below) — no key copy, keys stay on client\n> 2. **Pull-via-jump** (section below) — data passes through client, no key on remote\n> 3. **Temporary key copy** — LAST RESORT, only with a dedicated single-purpose key that has minimal access\n>\n> **If you must use the temporary key pattern:**\n> - Create a **dedicated single-purpose key** with access ONLY to the destination server\n> - Never use your primary key\n> - Verify cleanup: `ssh server-a 'ls -la /tmp/transfer_key'` after rsync\n> - The key file WILL PERSIST if the process is interrupted (kill -9, network drop, crash)\n\nWhen you need to sync data **between two remote servers** but local\nForwardAgent is not working (no ssh-agent running) and you have no root\nto install rsync locally.\n\n## Step Zero: Verify source and destination\n\nBefore running rsync, **always confirm**:\n\n1. **The source path exists** on server A — the user might refer to a\n   path that no longer exists (e.g. `/var/www/html` changed to `/var/www/`):\n   ```bash\n   ssh user@server-a 'ls -la /path/source/' 2>&1\n   ```\n   If it fails, inspect `/var/www/` or `/srv/` to find the actual structure.\n\n2. **The destination directory is writable** by the user on server B:\n   ```bash\n   ssh -p <PORT> user@server-b 'touch /path/dest/.test_write && rm /path/dest/.test_write' 2>&1\n   ```\n   If it fails (Permission denied):\n   - Try `sudo mkdir -p /path/dest/` (some servers have NOPASSWD)\n   - If sudo **also** fails (needs password), **use an alternative path**\n     where the user already has write permission (e.g. `~/backup/` or\n     `/home/user/backup/`)\n   - Inform the user about the alternative path and offer future adjustment\n\n## The Problem\n\n```\nYou (client)\n  ├── have the SSH key on disk\n  ├── NO ssh-agent running\n  └── CANNOT install packages (no sudo/root)\n       │\n       ▼\n   Server A ──── ??? ────► Server B\n   (source)                (destination)\n```\n\n`rsync` does not support two remote destinations directly. ForwardAgent\ndoes not work if there is no agent running locally to forward.\n\n**⚠️  Do NOT copy private keys to remote servers.** See the safe variants below.\n\n## Variants\n\n### Via SSH tunneled with ForwardAgent (preferred)\n\nIf ssh-agent is running and server-a accepts ForwardAgent:\n\n```bash\nssh -A user@server-a \\\n  'rsync -avz -e \"ssh -p <DEST_PORT> -o StrictHostKeyChecking=yes\" \\\n     /path/source/ \\\n     user@server-b:/path/dest/'\n```\n\n### Pull via jump (data passes through client)\n\nWhen the above is not viable (e.g. server-a has no rsync):\n\n```bash\n# Pull from server-a to local /tmp/\nrsync -avz -e \"ssh -i ~/.ssh/your-key\" \\\n  user@server-a:/path/source/ /tmp/staging/\n\n# Push from local /tmp/ to server-b\nrsync -avz -e \"ssh -i ~/.ssh/your-key -p <PORT>\" \\\n  /tmp/staging/ user@server-b:/path/dest/\n\n# Clean up staging\nrm -rf /tmp/staging\n```\n\n⚠️ **Downside:** all traffic passes through the client twice.\n\n<details>\n<summary>⚠️  DANGEROUS: Temporary key copy (LAST RESORT — click to expand)</summary>\n\n> **🚫  CREDENTIAL STAGING VULNERABILITY — READ BEFORE USING**\n>\n> Copying a private SSH key to a remote server (even temporarily) exposes\n> credentials to a host you may not fully trust. If server-a is compromised,\n> the attacker gains access to every server that key can authenticate to.\n>\n> **Only proceed if ALL of the following are true:**\n> - [ ] You created a **dedicated single-purpose key** with access ONLY to server-b\n> - [ ] Your primary key is NOT used — this is a throwaway key scoped to one destination\n> - [ ] ForwardAgent and pull-via-jump are genuinely impossible\n> - [ ] You have explicit user approval\n> - [ ] You will verify cleanup: `ssh server-a 'ls -la /dev/shm/transfer_key'` after rsync\n>\n> **The key file WILL PERSIST if the process is interrupted** (kill -9, network drop, crash).\n\n```bash\n# 1. Copy the key to the source server (use /dev/shm, not /tmp)\nscp ~/.ssh/single-purpose-key user@server-a:/dev/shm/transfer_key\n\n# 2. Set correct permission (SSH requires 600)\nssh user@server-a 'chmod 600 /dev/shm/transfer_key'\n\n# 3. Execute rsync from server-a → server-b\nssh user@server-a \\\n  'rsync -avz --delete --progress \\\n     -e \"ssh -i /dev/shm/transfer_key -p <DEST_PORT> -o StrictHostKeyChecking=yes\" \\\n     /path/source/ \\\n     user@<SERVER_B>:/path/dest/'\n\n# 4. Remove the temporary key and verify\nssh user@server-a 'shred -u /dev/shm/transfer_key && echo \"CLEANUP VERIFIED\"'\n```\n\n</details>\n\n## Pitfalls\n\n| Problem | Cause | Solution |\n|----------|-------|---------|\n| `Permission denied` at step 3 | Key does not have 600 permission on server A | Run `chmod 600 /dev/shm/transfer_key` |\n| `rsync: command not found` (local) | Client without rsync, no sudo | Use pull-via-jump instead |\n| `IO error encountered -- skipping file deletion` | File with denied permission on source server (e.g. Docker volume) | Ignore — `--delete` skips, remove manually if needed |\n| False positive: `--delete` flagged as MEDIUM risk | Tool detects `rsync --delete` as destructive | Explain it's a mirror (not a wipe), ask for approval |\n| `rsync error: some files/attrs were not transferred (code 23)` | Docker volumes (pgdata, db/mysql) without read permission | Normal for volumes — source code was transferred; verify with `du -sh` |\n| Key cleanup fails and key stays on server A | Script interrupted mid-way | Always verify after: `ssh server-a 'ls -la /dev/shm/transfer_key'` |\n\n## Checklist\n\n- [ ] Does server A have `rsync` installed? (99% of Linux servers do)\n- [ ] Does the SSH alias resolve? Test with `ssh <alias> 'echo OK'` before scp\n- [ ] Does the copied key have 600 permission?\n- [ ] After rsync, was the key removed?\n- [ ] Verify destination with `ls -la` + `du -sh`\n- [ ] Does server A have outbound access to server B (firewall?)\n- [ ] Does the source path exist? (confirm with `ls -la` before rsync)\n\nFile v2.4.1:references/testing-pitfalls-2026-07-23.md\n\n# Testing Pitfalls — Discovered 2026-07-23\n\nPitfalls found during live deployment testing of ssh-executor v2.2.1.\n\n## 1. UserKnownHostsFile /dev/null Nullifies StrictHostKeyChecking\n\n**Symptom:** `Host key verification failed` (exit 255) even though the host key is in `~/.ssh/known_hosts`.\n\n**Root cause:** `~/.ssh/config` has `UserKnownHostsFile /dev/null` — SSH effectively has no trust store. When our skill sets `StrictHostKeyChecking=yes` (hardening v2.2.0), strict checking fails because there's nothing to check against.\n\n**Fix applied:** In `ssh-run-native.sh`, when `HOST_KEY_CHECKING=yes`, the script now forces:\n```\n-o UserKnownHostsFile=${HOME}/.ssh/known_hosts\n```\nThis overrides the `/dev/null` from the SSH config and points to the real known_hosts file. A `touch` ensures the file exists (SSH refuses nonexistent UserKnownHostsFile paths).\n\n**Checklist:**\n- [ ] Grep SSH config for `UserKnownHostsFile` directives\n- [ ] Ensure `${HOME}/.ssh/known_hosts` is writable\n- [ ] Use `ssh-keyscan` to add host keys before connecting\n\n## 2. Ghost Exit Code 1 from ssh-keys.sh restore\n\n**Symptom:** `ssh-keys.sh restore id-rsa` prints \"✓ SSH key loaded into ssh-agent\" to stderr (success messages) but returns exit code 1 instead of 0. This causes `ssh-run-native.sh` to incorrectly route to the \"Failed to restore SSH key\" error handler (exit 98).\n\n**Root cause:** Combined effect of `set -euo pipefail` + trap + double cleanup:\n\n1. Success path in `cmd_restore()` does `shred -u \"$tmp_key\"` + `rm -f \"$tmp_key\"` → deletes temp file\n2. Prints \"✓ loaded\" success messages  \n3. Calls `exit 0`\n4. `exit 0` triggers EXIT trap: `shred -u \"$tmp_key\" 2>/dev/null; rm -f \"$tmp_key\"`\n5. File is already deleted → `shred -u` fails\n6. `set -e` catches the trap failure and converts to exit 1\n\n**Fix applied:** Changed trap from `shred -u ...; rm -f ...` to just `rm -f`. The `rm -f` on a missing file always succeeds (exit 0). The `shred -u` secure wipe still happens in the explicit code paths (success + error branches) before the trap fires. The trap is now purely a safety net for interrupted execution — it removes the temp file but does not attempt secure wipe (acceptable since /tmp is typically tmpfs/RAM).\n\n## 3. --host-key-checking Silently Ignored by Python Backend\n\n**Symptom:** Using `--host-key-checking no` with the Python/Paramiko backend had no effect — the backend always used its default policy.\n\n**Root cause:** `ssh-run.sh` (dispatcher) had `--host-key-checking) shift 2 ;; # ignored in Python backend`. The variable was never captured or passed to `ssh-client.py`.\n\n**Fix applied:** \n- `ssh-run.sh`: Capture `HOST_KEY_CHECKING` variable, pass to Python backend via `--host-key-checking \"$HOST_KEY_CHECKING\"`\n- `ssh-client.py`: Already supports the flag (added in v2.2.0), defaults to `RejectPolicy`\n\nFile v2.4.1:references/troubleshooting-field-notes.md\n\n# Troubleshooting Field Notes\n\nProduction deployment and audit remediation learnings.\n\n---\n\n## Multiplexing + stale group membership\n\n**Symptom:** `usermod -aG sudo <user>` succeeds, but sudo still fails.\n**Cause:** ControlMaster socket from before group change.\n**Fix:** `ssh -O exit user@host` then retry — new session gets new groups.\n\n---\n\n## UserKnownHostsFile /dev/null\n\n**Symptom:** StrictHostKeyChecking=yes rejects known hosts.\n**Cause:** SSH config has `UserKnownHostsFile /dev/null`.\n**Fix:** Skill now overrides to real known_hosts when strict mode is active (v2.2.1+).\n\n---\n\n## trap + shred + set -e = phantom exit 1\n\n**Symptom:** ssh-keys.sh restore prints success but returns exit 1.\n**Cause:** EXIT trap runs `shred -u` on already-deleted file, `set -e` converts to exit 1.\n**Fix (v2.2.1):** `rm -f` in trap, shred runs explicitly in code path.\n\n---\n\n## Sudo requires --confirm-dangerous (v2.3.1+)\n\n**Before:** --sudo auto-set CONFIRM_DANGEROUS=1.\n**Now:** --sudo blocks with exit 99 unless --confirm-dangerous is passed.\n**LLM flow:** present command → user approves → retry with --confirm-dangerous.\n\n---\n\n## VAULT_RESOLVER_BIN validation (v2.3.1)\n\n**Risk:** Env var flows directly to subprocess.run.\n**Fix:** `shutil.which()` for bare names, `os.path.isfile()` + `os.access(X_OK)` for paths.\n\n---\n\n## Paramiko RejectPolicy + known_hosts (v2.3.1)\n\n**Before:** RejectPolicy without loaded known_hosts meant everything rejected.\n**Fix:** Load `~/.ssh/known_hosts` + `/etc/ssh/ssh_known_hosts` into Paramiko client.\n\nFile v2.4.1:references/vault-backends.md\n\n# Vault Backend Integration\n\nThis skill supports **any** credential vault via the `VAULT_RESOLVER_BIN` environment variable. The backend must accept a JSON request on stdin and return a JSON response on stdout.\n\n## API Contract\n\n### Input (stdin)\n```json\n{\n  \"ids\": [\"item-name/field-name\", ...]\n}\n```\n\n### Output (stdout)\n```json\n{\n  \"values\": {\n    \"item-name/field-name\": \"<base64-encoded value>\",\n    ...\n  }\n}\n```\n\n### Exit codes\n- `0`: success\n- Non-zero: failure (skill will handle gracefully)\n\n## Built-in Backends\n\n### vault-resolver (Hermes Agent default)\n\nZero-config on Hermes Agent. Wraps Vaultwarden's API directly.\n\n```bash\n# Auto-detected — no setup needed\nssh-run.sh --host my-server --vault-key my-key -- 'uptime'\n```\n\nKey naming convention: `ssh-<name>/ssh_private_key` (base64), `ssh-<name>/ssh_password` (plain text), `sudo-<name>/sudo_password` (plain text).\n\n## Custom Backend Wrappers\n\n### Bitwarden CLI (`bw`)\n\n```bash\nexport VAULT_RESOLVER_BIN=\"bw\"\n\n# Requires bw CLI + session:\nbw login\nexport BW_SESSION=$(bw unlock --raw)\n```\n\nThe skill calls `bw` directly — ensure your items follow the naming convention or adapt with a wrapper.\n\n### 1Password CLI (`op`)\n\n```bash\nexport VAULT_RESOLVER_BIN=\"op\"\n\n# Requires op CLI + signin:\nop signin\n```\n\n### HashiCorp Vault\n\n```bash\n# Wrapper script at /usr/local/bin/vault-wrapper:\ncat > /usr/local/bin/vault-wrapper << 'SCRIPT'\n#!/usr/bin/env bash\n# Reads JSON from stdin, resolves vault paths, outputs JSON to stdout\npython3 - << 'PY'\nimport json, subprocess, sys, os\n\nreq = json.load(sys.stdin)\nresult = {\"values\": {}}\n\nfor id_str in req.get(\"ids\", []):\n    # Map ssh-executor naming to Vault paths\n    # ssh-mykey/ssh_private_key → secret/ssh/mykey\n    parts = id_str.split(\"/\")\n    item, field = parts[0], parts[1]\n    \n    # Remove prefix\n    if item.startswith(\"ssh-\"):\n        name = item[4:]\n        vault_path = f\"secret/ssh/{name}\"\n    elif item.startswith(\"sudo-\"):\n        name = item[5:]\n        vault_path = f\"secret/sudo/{name}\"\n    else:\n        vault_path = f\"secret/{item}\"\n    \n    try:\n        p = subprocess.run(\n            [\"vault\", \"kv\", \"get\", \"-field=\" + field, vault_path],\n            capture_output=True, text=True, timeout=10\n        )\n        if p.returncode == 0:\n            result[\"values\"][id_str] = p.stdout.strip()\n    except Exception:\n        pass\n\nprint(json.dumps(result))\nPY\nSCRIPT\nchmod +x /usr/local/bin/vault-wrapper\n\nexport VAULT_RESOLVER_BIN=\"/usr/local/bin/vault-wrapper\"\n```\n\n## No Vault (Key Files Only)\n\nIf you don't use a vault, skip `--vault-key` entirely:\n\n```bash\n# Direct key file\nssh-run.sh --host my-server --user ubuntu --key ~/.ssh/id_rsa -- 'uptime'\n\n# Environment variable\n# ⚠️  WARNING: env vars can leak via shell history, /proc, crash reports, and child processes.\n#    Prefer vault-based auth (--ssh-pass-vault) for production. Use env vars only in ephemeral CI.\nSSH_PASS=\"<your-password>\" ssh-run.sh --host my-server --user ubuntu -- 'uptime'\n\n# Interactive prompt\nssh-run.sh --host my-server --user ubuntu --ssh-pass-ask -- 'uptime'\n# → LLM will ask the user, then retry with SSH_PASS\n```\n\n## Testing Your Backend\n\n```bash\n# Test that your backend works:\necho '{\"ids\": [\"ssh-test/ssh_private_key\"]}' | $VAULT_RESOLVER_BIN resolve\n\n# Expected output: {\"values\": {\"ssh-test/ssh_private_key\": \"<base64>\"}}\n```\n\nFile v2.4.1:references/vault-key-format.md\n\n# SSH Key Format in Vaultwarden\n\n## Expected Format\n\nThe `ssh-keys.sh` script expects SSH keys stored as **custom fields**, not in the notes field.\n\n| Field | Content | Format |\n|-------|---------|--------|\n| `ssh_private_key` | Private key | base64-encoded |\n| `ssh_public_key` | Public key (optional) | base64-encoded |\n\n## How Keys Should Be Stored\n\nAlways use `ssh-keys.sh store <name> <path>` to store a key:\n\n```bash\nssh-keys.sh store prod-server ~/.ssh/id_ed25519_prod\n```\n\nThis creates a vault item named `ssh-prod-server` with proper fields:\n- `ssh_private_key` (base64 of the private key)\n- `ssh_public_key` (base64 of the .pub file, if exists)\n\n## When Keys Are in Notes\n\nIf a key was stored manually (e.g., via Vaultwarden UI, pasted into Secure Notes),\nit won't be found by `ssh-keys.sh restore`. Two options:\n\n1. **Re-store properly** (recommended):\n   ```bash\n   # ⚠️  WARNING: Writing private key to disk — use /dev/shm (RAM-backed)\n   #    to avoid persistence on physical storage. Verify cleanup after.\n   vault-resolver get ssh-id-rsa/notes > /dev/shm/key\n   chmod 600 /dev/shm/key\n   # Re-store in correct format (key loaded into vault, never exposed in chat)\n   ssh-keys.sh store id-rsa /dev/shm/key\n   shred -u /dev/shm/key\n   ```\n\n2. **Resolve via notes fallback** (if vault-resolver has the patch):\n   ```bash\n   vault-resolver get ssh-id-rsa/notes\n   ```\n\n## Resolution Flow\n\nWhen `ssh-run.sh --vault-key <name>` is called:\n\n1. `ssh-run-native.sh` → `ssh-keys.sh restore <name>`\n2. `ssh-keys.sh` calls `vault-resolver resolve` with key `ssh-<name>/ssh_private_key`\n3. vault-resolver looks for a custom field named `ssh_private_key`\n4. If not found and fallback is active, checks other fields then `notes`\n\n## Troubleshooting\n\nIf restore says \"not found\":\n```bash\n# List all vault items matching ssh\nvault-resolver resolve  # with JSON: {\"ids\":[\"ssh-*/ssh_private_key\"]}\n\n# Check what fields the item actually has\n# Use vault-resolver get with field name known to exist\n```\n\nArchive v2.4.0: 20 files, 46701 bytes\n\nFiles: manifest.json (630b), references/docker-diagnostics-without-cli.md (5596b), references/multiplexing-verification.md (3453b), references/remote-backup-cleanup.md (3321b), references/safety.md (4212b), references/security-audit-2026-07-clawhub.md (3469b), references/server-to-server-rsync.md (6280b), references/testing-pitfalls-2026-07-23.md (2834b), references/troubleshooting-field-notes.md (1537b), references/vault-backends.md (3355b), references/vault-key-format.md (2006b), references/vault-ssh-integration.md (4975b), release.json (489b), scripts/ssh-client.py (10886b), scripts/ssh-keys.sh (8962b), scripts/ssh-run-native.sh (9030b), scripts/ssh-run.sh (6697b), skill-card.md (3029b), SKILL.md (25488b), _meta.json (131b)\n\nFile v2.4.0:SKILL.md\n\n---\nname: ssh-executor\ndescription: Execute commands on remote hosts over SSH with structured discovery, pluggable credential backends, and safety guardrails. Supports SSH key-based and password-based auth with multiple credential sources (vault, env vars, direct files). Includes 6-step discovery protocol and standardized JSON output. Host-key verification is strict by default. Cross-platform: works on Hermes Agent, OpenClaw, Claude Code, Codex, and any LLM environment with bash + python3.\nmetadata:\n  platforms: [\"hermes\", \"openclaw\", \"claude-code\", \"codex\", \"generic\"]\n  os: [\"linux\", \"darwin\"]\n  requires: { bins: [\"bash\", \"python3\", \"base64\"], optional_bins: [\"ssh\", \"ssh-agent\", \"ssh-add\", \"ssh-keygen\", ] }\n---\n\n# SSH Executor\n\nExecute remote commands over SSH securely. Platform-agnostic — configure once, use anywhere.\n\n## Scope\n\n| Use for | Don't use for |\n|---------|---------------|\n| Connecting to Linux servers via alias, IP, user, port | Storing/displaying credentials in chat or logs |\n| Inspection commands (read-only) | Running destructive commands without user confirmation |\n| Maintenance with explicit `--confirm-dangerous` | Bypassing host-key verification (strict by default) |\n| Any credential backend: vault, env vars, key files, password prompts | Exposing private key contents anywhere |\n\n### Credential Sources (Tiered)\n\nThe skill works with **any** credential source. Configure what you have — no mandatory vault dependency.\n\n| Tier | Source | How | Example |\n|------|--------|-----|---------|\n| **1. Vault** | Any vault that outputs to stdout | `--vault-key <name>` + backend script | Bitwarden CLI, 1Password CLI, vault-resolver, HashiCorp Vault |\n\nThe default vault backend is **vault-resolver** (Hermes-native, Vaultwarden API). To use another vault:\n\n```bash\n# 1Password CLI\nexport VAULT_RESOLVER_BIN=\"op\"\n\n# Bitwarden CLI\nexport VAULT_RESOLVER_BIN=\"bw\"\n\n# Custom script (must accept JSON on stdin, output JSON on stdout)\nexport VAULT_RESOLVER_BIN=\"/path/to/your/vault-wrapper\"\n```\n\nSee `references/vault-backends.md` for setup guides for each platform.\n\n## Platform Setup\n\n### Hermes Agent / OpenClaw (native)\n```bash\n# vault-resolver is auto-detected at /opt/data/bin/vault-resolver\n# Store a key:\nssh-keys.sh store my-server ~/.ssh/id_rsa\n# Use:\nssh-run.sh --host my-server --vault-key my-server -- 'uptime'\n```\n\n### Claude Code / Codex / Generic LLM\n```bash\n# No vault — use env vars or key files:\nexport SSH_KEY=\"$(cat ~/.ssh/id_rsa)\"\nssh-run.sh --host my-server --user ubuntu --key ~/.ssh/id_rsa -- 'uptime'\n\n```\n\n### CI/CD / GitHub Actions\n```bash\nssh-run.sh --host ${{ secrets.SSH_HOST }} \\\n           --user ${{ secrets.SSH_USER }} \\\n           --key <(echo \"${{ secrets.SSH_KEY }}\") \\\n           -- 'deploy.sh'\n```\n\n## Server Discovery\n\nThe remote server inventory (aliases, IPs, ports, users) lives in the **wiki** at `/opt/data/wiki/entities/remote-servers.md`. When the user mentions an alias not in `~/.ssh/config`, check this file first — it contains the full table of all known servers.\n\n**Warning:** The actual `~/.ssh/config` may differ from what the wiki documents. In particular, the `User` directive is often **not** present for individual hosts. Always pass `--user <user>` explicitly in that case. The wiki contains the per-host user table.\n\nDo not use this skill to:\n- Store or transmit passwords in chat, logs, or memory files\n- Expose private key contents in chat, logs, or memory\n- Bypass host key verification without explicit user permission (default is strict)\n- Run destructive commands without user confirmation\n- Use `restore-to-file` unnecessarily — prefer ssh-agent\n\n## Recommended Flow\n\n1. Discover the target: alias, hostname, port, user — from SSH config or the request.\n2. Validate the host with the user before connecting.\n3. Decide risk level: read-only (safe) or mutating (requires confirmation).\n4. Execute with `scripts/ssh-run.sh` and the correct parameters.\n5. Report the result: success, exit code, stdout/stderr, next steps.\n\n## stdout/stderr Contract\n\n`ssh-run.sh` and its backends follow a strict output contract:\n\n| Stream | Content | Format |\n|--------|---------|--------|\n| **stdout** | Pure JSON with command result | `{\"success\": bool, \"exit_code\": int, \"stdout\": \"...\", \"stderr\": \"...\", ...}` |\n| **stderr** | Status messages (vault, warnings, progress) | Free text |\n\n**Rule:** stdout is **always** parseable by `json.load()`. It never contains vault messages, ssh warnings, or any non-JSON text. This enables pipelines like:\n\n```bash\nresult=$(ssh-run.sh --host <your-host> --user <user> --vault-key id-rsa -- 'df -h' 2>/dev/null)\necho \"$result\" | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d['stdout'])\"\n```\n\n## Security Rules\n\n- **Validate host with user.** Confirm hostname/IP before any connection, especially if inferred from config.\n- **Read-only first.** Start with inspection commands. Commands that modify state (systemctl, rm, apt, docker, firewall, etc.) require `--confirm-dangerous` and explicit user approval.\n- **Dangerous command heuristic is not exhaustive.** Always treat unknown or chained commands with caution.\n- **Private keys never go to chat, logs, stdout, or memory files.** The `ssh-run.sh` JSON output intentionally omits key paths.\n- **Vault credentials are resolved in RAM and passed to SSH via stdin pipe only.** The vault binary path is validated before execution.\n- **Key disk exposure is minimized but not eliminated in all paths.** Without `ssh-agent`, `ssh-keys.sh restore` writes decrypted key material to a temp file (`/tmp/ssh-vault-*` or `/dev/shm/ssh-vault-*`) that is cleaned up by trap handlers. `restore-to-file` writes to a caller-specified path. Interruption by `kill -9` prevents cleanup — prefer `ssh-agent` to avoid disk exposure entirely.\n- **Sensitive log access requires approval.** Reading `/var/log/auth.log`, `/var/log/secure`, or similar security logs exposes usernames, source IPs, and authentication patterns — treat as privileged telemetry. Always get explicit user approval and consider redacting PII (usernames, IPs) before sharing results.\n- **Prefer `/dev/shm` over `/tmp` for temporary key material.** `/dev/shm` is RAM-backed tmpfs \n\nArchive v2.3.6: 20 files, 51386 bytes\n\nFiles: manifest.json (521b), references/docker-diagnostics-without-cli.md (5596b), references/multiplexing-verification.md (3453b), references/remote-backup-cleanup.md (3611b), references/safety.md (4980b), references/security-audit-2026-07-clawhub.md (3469b), references/server-to-server-rsync.md (6280b), references/testing-pitfalls-2026-07-23.md (2834b), references/troubleshooting-field-notes.md (1537b), references/vault-backends.md (3355b), references/vault-key-format.md (2006b), references/vault-ssh-integration.md (4975b), release.json (558b), scripts/ssh-client.py (10886b), scripts/ssh-keys.sh (8962b), scripts/ssh-run-native.sh (16655b), scripts/ssh-run.sh (7710b), skill-card.md (3382b), SKILL.md (33222b), _meta.json (131b)\n\nArchive v2.3.5: 20 files, 51119 bytes\n\nFiles: manifest.json (556b), references/docker-diagnostics-without-cli.md (5596b), references/multiplexing-verification.md (3453b), references/remote-backup-cleanup.md (3611b), references/safety.md (4847b), references/security-audit-2026-07-clawhub.md (3469b), references/server-to-server-rsync.md (6280b), references/testing-pitfalls-2026-07-23.md (2834b), references/troubleshooting-field-notes.md (1537b), references/vault-backends.md (3355b), references/vault-key-format.md (2006b), references/vault-ssh-integration.md (4975b), release.json (506b), scripts/ssh-client.py (10886b), scripts/ssh-keys.sh (8962b), scripts/ssh-run-native.sh (16655b), scripts/ssh-run.sh (7710b), skill-card.md (2964b), SKILL.md (33222b), _meta.json (131b)\n\nArchive v2.3.4: 20 files, 51104 bytes\n\nFiles: manifest.json (627b), references/docker-diagnostics-without-cli.md (5596b), references/multiplexing-verification.md (3453b), references/remote-backup-cleanup.md (3611b), references/safety.md (4847b), references/security-audit-2026-07-clawhub.md (3469b), references/server-to-server-rsync.md (6280b), references/testing-pitfalls-2026-07-23.md (2834b), references/troubleshooting-field-notes.md (1537b), references/vault-backends.md (3157b), references/vault-key-format.md (2006b), references/vault-ssh-integration.md (4975b), release.json (486b), scripts/ssh-client.py (10886b), scripts/ssh-keys.sh (8835b), scripts/ssh-run-native.sh (16655b), scripts/ssh-run.sh (7502b), skill-card.md (3604b), SKILL.md (33222b), _meta.json (131b)\n\nArchive v2.3.3: 20 files, 51128 bytes\n\nFiles: manifest.json (822b), references/docker-diagnostics-without-cli.md (5596b), references/multiplexing-verification.md (3453b), references/remote-backup-cleanup.md (3611b), references/safety.md (4847b), references/security-audit-2026-07-clawhub.md (3469b), references/server-to-server-rsync.md (6280b), references/testing-pitfalls-2026-07-23.md (2834b), references/troubleshooting-field-notes.md (1537b), references/vault-backends.md (3156b), references/vault-key-format.md (2006b), references/vault-ssh-integration.md (4975b), release.json (692b), scripts/ssh-client.py (10886b), scripts/ssh-keys.sh (8835b), scripts/ssh-run-native.sh (16655b), scripts/ssh-run.sh (7502b), skill-card.md (3216b), SKILL.md (33221b), _meta.json (131b)\n\nArchive v2.3.2: 20 files, 50729 bytes\n\nFiles: manifest.json (819b), references/docker-diagnostics-without-cli.md (5596b), references/multiplexing-verification.md (3453b), references/remote-backup-cleanup.md (3611b), references/safety.md (4847b), references/security-audit-2026-07-clawhub.md (3469b), references/server-to-server-rsync.md (6276b), references/testing-pitfalls-2026-07-23.md (2834b), references/troubleshooting-field-notes.md (1537b), references/vault-backends.md (3156b), references/vault-key-format.md (2006b), references/vault-ssh-integration.md (4975b), release.json (860b), scripts/ssh-client.py (10886b), scripts/ssh-keys.sh (8643b), scripts/ssh-run-native.sh (16655b), scripts/ssh-run.sh (7502b), skill-card.md (2638b), SKILL.md (32689b), _meta.json (131b)\n\nArchive v2.3.1: 20 files, 49477 bytes\n\nFiles: manifest.json (780b), references/docker-diagnostics-without-cli.md (5596b), references/multiplexing-verification.md (3453b), references/remote-backup-cleanup.md (3283b), references/safety.md (4847b), references/security-audit-2026-07-clawhub.md (3469b), references/server-to-server-rsync.md (5383b), references/testing-pitfalls-2026-07-23.md (2834b), references/troubleshooting-field-notes.md (1537b), references/vault-backends.md (3156b), references/vault-key-format.md (1832b), references/vault-ssh-integration.md (4975b), release.json (594b), scripts/ssh-client.py (10886b), scripts/ssh-keys.sh (8643b), scripts/ssh-run-native.sh (15551b), scripts/ssh-run.sh (7245b), skill-card.md (3318b), SKILL.md (30598b), _meta.json (131b)\n\nArchive v1.1.3: 6 files, 9586 bytes\n\nFiles: references/safety.md (3100b), release.json (137b), scripts/ssh-run.sh (8191b), skill-card.md (2770b), SKILL.md (5838b), _meta.json (131b)","readmeExcerpt":"Skill: ssh-executor Owner: rickkbarbosa Summary: Execute commands on remote hosts over SSH with structured discovery protocol, vault-integrated auth, and safety guardrails for remote server administration. Use when the user asks to access remote servers, inspect state, map runtime/containers/network/data, or run single commands. Supports SSH keys Tags: latest:2.4.2 Version history: v2.4.2 | 2026-07-24T02:37:04.179Z |","codeSnippets":[],"executableExamples":[{"language":"bash","snippet":"# 1Password CLI\nexport VAULT_RESOLVER_BIN=\"op\"\n\n# Bitwarden CLI\nexport VAULT_RESOLVER_BIN=\"bw\"\n\n# Custom script (must accept JSON on stdin, output JSON on stdout)\nexport VAULT_RESOLVER_BIN=\"/path/to/your/vault-wrapper\""},{"language":"bash","snippet":"# vault-resolver is auto-detected at /opt/data/bin/vault-resolver\n# Store a key:\nssh-keys.sh store my-server ~/.ssh/id_rsa\n# Use:\nssh-run.sh --host my-server --vault-key my-server -- 'uptime'"},{"language":"bash","snippet":"# No vault — use env vars or key files:\nexport SSH_KEY=\"$(cat ~/.ssh/id_rsa)\"\nssh-run.sh --host my-server --user ubuntu --key ~/.ssh/id_rsa -- 'uptime'\n\n# Or with password (requires SSH_EXECUTOR_ALLOW_DANGEROUS=1):\nSSH_EXECUTOR_ALLOW_DANGEROUS=1 SSH_PASS=\"<your-password>\" ssh-run.sh --host my-server --user ubuntu -- 'uptime'"},{"language":"bash","snippet":"ssh-run.sh --host ${{ secrets.SSH_HOST }} \\\n           --user ${{ secrets.SSH_USER }} \\\n           --key <(echo \"${{ secrets.SSH_KEY }}\") \\\n           -- 'deploy.sh'"},{"language":"bash","snippet":"result=$(ssh-run.sh --host <your-host> --user <user> --vault-key id-rsa -- 'df -h' 2>/dev/null)\necho \"$result\" | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d['stdout'])\""},{"language":"bash","snippet":"# Enable dangerous operations (password auth + sudo):\nexport SSH_EXECUTOR_ALLOW_DANGEROUS=1\nssh-run.sh --host <host> --sudo --sudo-pass-vault <name> -- 'systemctl restart nginx'\n\n# Without the env var → exit 98 \"requires SSH_EXECUTOR_ALLOW_DANGEROUS=1\""}],"parameters":null,"dependencies":[],"permissions":[],"extractedFiles":[{"path":"SKILL.md","content":"---\nname: ssh-executor\ndescription: Execute commands on remote hosts over SSH with structured discovery, pluggable credential backends, and safety guardrails. Supports SSH key-based and password-based auth with multiple credential sources (vault, env vars, direct files). Includes 6-step discovery protocol and standardized JSON output. Host-key verification is strict by default. Cross-platform: works on Hermes Agent, OpenClaw, Claude Code, Codex, and any LLM environment with bash + python3.\nmetadata:\n  platforms: [\"hermes\", \"openclaw\", \"claude-code\", \"codex\", \"generic\"]\n  os: [\"linux\", \"darwin\"]\n  requires: { bins: [\"bash\", \"python3\", \"base64\"], optional_bins: [\"ssh\", \"ssh-agent\", \"ssh-add\", \"ssh-keygen\", \"sshpass\"] }\n---\n\n# SSH Executor\n\nExecute remote commands over SSH securely. Platform-agnostic — configure once, use anywhere.\n\n## Scope\n\n| Use for | Don't use for |\n|---------|---------------|\n| Connecting to Linux servers via alias, IP, user, port | Storing/displaying credentials in chat or logs |\n| Inspection commands (read-only) | Running destructive commands without user confirmation |\n| Maintenance with explicit `--confirm-dangerous` | Bypassing host-key verification (strict by default) |\n| Any credential backend: vault, env vars, key files, password prompts | Exposing private key contents anywhere |\n\n### Credential Sources (Tiered)\n\nThe skill works with **any** credential source. Configure what you have — no mandatory vault dependency.\n\n| Tier | Source | How | Example |\n|------|--------|-----|---------|\n| **1. Vault** | Any vault that outputs to stdout | `--vault-key <name>` + backend script | Bitwarden CLI, 1Password CLI, vault-resolver, HashiCorp Vault |\n| **2. Env vars** | Environment variables | `SSH_PASS`, `SSH_SUDO_PASS`, `SSH_KEY` | CI/CD pipelines, containerized agents |\n| **3. Direct** | Key files, interactive prompts | `--key <path>`, `--ssh-pass-ask`, `--sudo-pass-ask` | Local development, one-off access |\n\nThe default vault backend is **vault-resolver** (Hermes-native, Vaultwarden API). To use another vault:\n\n```bash\n# 1Password CLI\nexport VAULT_RESOLVER_BIN=\"op\"\n\n# Bitwarden CLI\nexport VAULT_RESOLVER_BIN=\"bw\"\n\n# Custom script (must accept JSON on stdin, output JSON on stdout)\nexport VAULT_RESOLVER_BIN=\"/path/to/your/vault-wrapper\"\n```\n\nSee `references/vault-backends.md` for setup guides for each platform.\n\n## Platform Setup\n\n### Hermes Agent / OpenClaw (native)\n```bash\n# vault-resolver is auto-detected at /opt/data/bin/vault-resolver\n# Store a key:\nssh-keys.sh store my-server ~/.ssh/id_rsa\n# Use:\nssh-run.sh --host my-server --vault-key my-server -- 'uptime'\n```\n\n### Claude Code / Codex / Generic LLM\n```bash\n# No vault — use env vars or key files:\nexport SSH_KEY=\"$(cat ~/.ssh/id_rsa)\"\nssh-run.sh --host my-server --user ubuntu --key ~/.ssh/id_rsa -- 'uptime'\n\n# Or with password (requires SSH_EXECUTOR_ALLOW_DANGEROUS=1):\nSSH_EXECUTOR_ALLOW_DANGEROUS=1 SSH_PASS=\"<your-password>\" ssh-run.sh --host my-server --user ubuntu -- 'uptime"},{"path":"_meta.json","content":"{\n  \"ownerId\": \"kn7e7q1wce41djeky0k2zscsw184xh21\",\n  \"slug\": \"ssh-executor\",\n  \"version\": \"2.4.2\",\n  \"publishedAt\": 1784860624179\n}"},{"path":"references/docker-diagnostics-without-cli.md","content":"# Docker Diagnostics Without Docker CLI\n\nWhen the remote SSH user is **not in the docker group** and **has no sudo\nNOPASSWD** — but you still need to diagnose containers, networks, and services.\n\n## Techniques\n\n### 1. List active containers via cgroup\n\n```bash\nls /sys/fs/cgroup/system.slice/docker-*.scope 2>/dev/null | while read f; do\n  id=$(echo \"$f\" | grep -oP \"docker-\\K[a-f0-9]{12}\")\n  echo \"Container ID: $id\"\ndone\n```\n\n⚠️ Shows ALL active containers (not just running — any process in the\ncgroup). Useful as a starting point.\n\n### 2. Identify processes inside a container\n\n```bash\n# PIDs in the container\ncat /sys/fs/cgroup/system.slice/docker-<FULL_ID>.scope/cgroup.procs\n\n# What each PID executes\nfor pid in $(cat /sys/fs/cgroup/system.slice/docker-<ID>.scope/cgroup.procs); do\n  cmd=$(cat /proc/$pid/cmdline 2>/dev/null | tr \"\\0\" \" \" | head -c 200)\n  echo \"PID $pid: $cmd\"\ndone\n```\n\n### 3. Determine the container's network_mode\n\nCompare the process network namespace with the host's:\n\n```bash\nhost_ns=$(readlink /proc/1/ns/net)\ncontainer_ns=$(readlink /proc/<PID>/ns/net)\nif [ \"$host_ns\" = \"$container_ns\" ]; then\n  echo \"network_mode: host\"\nelse\n  echo \"network_mode: bridge (or other)\"\nfi\n```\n\n### 4. Find container IPs via bridge ARP\n\nKnowing which bridge is active (via `ip addr show`):\n\n```bash\n# List active bridges\nip addr show | grep -E \"^[0-9]+: br-|^[0-9]+: docker\" | grep UP\n\n# Show ARP neighbors (active IPs)\nip neigh show dev br-<ID>\n```\n\nExample output:\n```\n172.18.0.5 lladdr de:9a:bb:2b:2a:32 REACHABLE\n172.18.0.7 lladdr 66:1d:5b:2c:c2:08 STALE\n```\n\n**REACHABLE/STALE** IPs = active containers. **FAILED** = IP does not exist.\n\n### 5. Discover which process listens on which port\n\n```bash\nss -tlnp    # TCP listening, with PID\nss -ulnp    # UDP\n```\n\n⚠️ Without root, `ss -p` does not show the process (empty column). Use the port\nas a clue and cross-reference with `/proc/PID/cmdline`.\n\n### 6. Test TCP connectivity without tools\n\n```bash\ntimeout 2 bash -c \"echo > /dev/tcp/<IP>/<PORT>\" 2>/dev/null && echo \"OPEN\"\n```\n\nUseful for quickly scanning ports on a bridge or host — **only on networks you own or have explicit authorization to probe**:\n\n```bash\n# ⚠️ Network scanning — verify authorization before running\nfor ip in 172.18.0.{1..10}; do\n  timeout 1 bash -c \"echo > /dev/tcp/$ip/3306\" 2>/dev/null && echo \"$ip:3306 OK\"\ndone\n```\n\n### 7. Read container configuration via docker-compose\n\nNot every container was started with compose, but when it was:\n\n```bash\ncat /path/docker-compose.yml\n```\n\nPay special attention to:\n- `network_mode:` (host vs bridge)\n- `networks:` → `driver:` (host = shares host IP)\n- `ports:` (mapping)\n- `extra_hosts:` (hostname resolution)\n- `environment:` / `env_file:` (configuration variables)\n\n### 8. Check internal configuration files\n\nFor services like MySQL/MariaDB, even without container access:\n\n```bash\n# Process has config args in cmdline\ncat /proc/<PID>/cmdline | tr \"\\0\" \" \"\n\n# Check default config (host filesystem)\ncat /etc/mys"},{"path":"references/multiplexing-verification.md","content":"# Multiplexing Verification\n\nProcedure to confirm that ControlMaster multiplexing is working correctly.\n\n## Quick Checklist (6 steps)\n\n### 1. Does the local socket exist?\n\n```bash\nls -la /tmp/ssh-mux-<user>@<hostname>:<port>\n# Ex: /tmp/ssh-mux-root@10.0.0.5:22\n```\n\n**Expected:** UNIX socket (`srw-------`) with the first connection's timestamp.\n\n### 2. Does the socket timestamp stay unchanged across subsequent commands?\n\n```bash\nstat -c '%Y' /tmp/ssh-mux-<user>@<host>:<port>  # before\nssh-run.sh --host <host> --user <user> --vault-key <key> -- 'date'\nstat -c '%Y' /tmp/ssh-mux-<user>@<host>:<port>  # after — same value!\n```\n\n**Expected:** same epoch timestamp before and after.\n\n### 3. Only 1 mux process for the host?\n\n```bash\nps aux | grep \"[s]sh.*mux.*<host>\"\n```\n\n**Expected:** exactly 1 process `ssh: /tmp/ssh-mux-... [mux]`.\n\n### 4. Are TCP connections on the server consistent?\n\n```bash\nssh-run.sh --host <host> --user <user> --vault-key id-rsa -- 'ss -tn sport = :22'\n```\n\n**Expected:** the number of ESTABLISHED connections to the Hermes IP does not increase with each command.\n\n### 5. Does auth.log have only 1 Accepted per window?\n\n```bash\nssh-run.sh --host <host> --user <user> --vault-key <key> \\\n           --sudo --sudo-pass-vault <name> \\\n           -- 'sudo grep \"sshd.*Accepted.*<user>\" /var/log/auth.log | tail -5'\n```\n\n**Expected:** only 1 `Accepted publickey` entry for the entire batch of commands within the ControlPersist window.\n\n### 6. Is the JSON output clean (no vault pollution)?\n\n```bash\nresult=$(ssh-run.sh --host <host> --user <user> --vault-key id-rsa -- 'hostname' 2>/dev/null)\necho \"$result\" | python3 -c \"import sys,json; d=json.load(sys.stdin); print('OK:', d['stdout'].strip())\"\n```\n\n**Expected:** successful parse, no \"Retrieving SSH key...\" messages in stdout.\n\n## Signs that multiplexing is NOT active\n\n| Symptom | Likely cause |\n|---------|-------------|\n| Socket does not exist after command | `ssh-agent` is not running → Python backend (no ControlMaster) |\n| Multiple sockets with different timestamps | Different `--user` across calls → each user@host combination creates its own socket |\n| Socket exists but timestamp changes per command | `ControlPersist` expired between calls (>180s) |\n| `Permission denied (publickey)` | Wrong `--user` (local user instead of remote) |\n\n## Quick diagnostic command\n\n```bash\n# Close old socket, test 3 commands, verify\nssh -o ControlPath=/tmp/ssh-mux-<user>@%h:%p -O exit <user>@<host> 2>/dev/null || true\n\nTS1=$(bash ssh-run.sh --host <host> --user <user> --vault-key <key> --control-persist 180 -- 'hostname' 2>/dev/null | python3 -c \"import sys,json; print(json.load(sys.stdin)['stdout'].strip())\")\nS1=$(stat -c '%Y' /tmp/ssh-mux-<user>@<host>:<port> 2>/dev/null)\n\nTS2=$(bash ssh-run.sh --host <host> --user <user> --vault-key <key> -- 'date' 2>/dev/null | python3 -c \"import sys,json; print(json.load(sys.stdin)['stdout'].strip())\")\nS2=$(stat -c '%Y' /tmp/ssh-mux-<user>@<host>:<port> 2>/dev/null)\n\nTS3=$(ba"},{"path":"references/remote-backup-cleanup.md","content":"# Remote Backup Cleanup via SSH\n\n> **⚠️ DESTRUCTIVE OPERATIONS:** All commands in this document delete data permanently. \n> - **Always dry-run first** with `-ls` or `-printf` to verify what will be deleted\n> - **Require explicit user confirmation** before running any command with `-delete`\n> - Pass `--confirm-dangerous` AND set `SSH_EXECUTOR_ALLOW_DANGEROUS=1` when using `ssh-run.sh` for these commands\n> - See `safety.md` for the full confirmation policy\n\nClean up old backups on remote servers using `find -mtime +N -delete`.\n\n## Basic pattern\n\n```bash\n# With ssh-executor (requires SSH_EXECUTOR_ALLOW_DANGEROUS=1 + --confirm-dangerous):\nSSH_EXECUTOR_ALLOW_DANGEROUS=1 ssh-run.sh --host <host> --user <user> --vault-key <key> \\\n    --confirm-dangerous -- 'find /srv/backup -type f -mtime +15 -delete'\n\n# Direct SSH (manual, no guardrails):\nssh <user>@<host> 'find /srv/backup -type f -mtime +15 -delete'\n```\n\nThe `-delete` flag only works after `-type f` (prevents accidentally deleting directories).\n\n## Permission model — common pitfall\n\nDeleting a file does **not** require write permission on the **file** — it requires write permission (`w`) on the **directory** that contains it.\n\n### Quick diagnosis\n\n```bash\n# View complete hierarchy permissions\nnamei -l /srv/backup/2026-06-13/backup_file.tar.gz\n```\n\n### Typical scenario\n\n```\ndrwxr-xr-x root backup  srv/backup/        # backup group has r-x, missing w\ndrwxr-xr-x root backup  2026-06-13/        # backup group has r-x, missing w\n-rw-r--r-- root backup  file.tar.gz         # group read-only — irrelevant\n```\n\nEven if the **remote user** is in the `backup` group (via `groups` or `id`) and the **files** are `root:backup`, deletion fails with `Permission denied` if the directory lacks `w` for the group.\n\n### Solutions (in order of preference)\n\n| Approach | Requirement | Risks |\n|-----------|-----------|--------|\n| **sudo** | Remote user's sudo password | Simplest, but needs interaction |\n| **chmod g+w on directories** | Directory owner or sudo | Permission stays open; requires directory owner |\n| **Cron job as root** | Backup service runs as root | Ideal for automated routine |\n| **ACL** | Filesystem with ACL support | `setfacl -m g:backup:rwx /srv/backup` |\n\n### Real case example\n\nA common scenario encountered in production:\n\n- Server with backup directories owned by `root:backup`, permissions `drwxr-xr-x`\n- The remote user is in the `backup` group but lacks write permission on directories\n- Backup files are owned by `root:root` (or `root:backup`)\n- Deletion fails without sudo because directories lack `w` for the `backup` group\n\nThis is not a file permission issue — it's a **directory write permission** issue.\nThe solution is sudo, `chmod g+w` on directories, ACLs, or a cron job as root.\n\n## Useful commands\n\n```bash\n# Dry-run: list files older than 15 days with size\nssh <host> 'find /srv/backup /archive/backup -type f -mtime +15 -ls'\n\n# Count and total size\nssh <host> 'find /srv/backup /archive/backup -type f"}],"languages":[],"docsSourceLabel":"CLAWHUB","editorialOverview":"Execute commands on remote hosts over SSH with structured discovery protocol, vault-integrated auth, and safety guardrails for remote server administration. Use when the user asks to access remote servers, inspect state, map runtime/containers/network/data, or run single commands. Supports SSH keys Skill: ssh-executor Owner: rickkbarbosa Summary: Execute commands on remote hosts over SSH with structured discovery protocol, vault-integrated auth, and safety guardrails for remote server administration. Use when the user asks to access remote servers, inspect state, map runtime/containers/network/data, or run single commands. Supports SSH keys Tags: latest:2.4.2 Version history: v2.4.2 | 2026-07-24T02:37:04.179Z |","editorialQuality":{"score":100,"threshold":65,"status":"ready","wordCount":1616,"uniquenessScore":46,"reasons":[]}},"media":{"evidence":{"source":"no-media","verified":false,"confidence":"low","updatedAt":"2026-10-10T02:16:13.368Z","emptyReason":"No screenshots, media assets, or demo links are available."},"primaryImageUrl":null,"mediaAssetCount":0,"assets":[],"demoUrl":null},"ownerResources":{"evidence":{"source":"unclaimed","verified":false,"confidence":"low","updatedAt":"2026-10-10T02:16:13.368Z","emptyReason":"This page has not been claimed by the agent owner."},"hasCustomPage":false,"customPageUpdatedAt":null,"customLinks":[],"structuredLinks":{"docsUrl":null,"demoUrl":null,"supportUrl":null,"pricingUrl":null,"statusUrl":null},"customPage":null},"relatedAgents":{"evidence":{"source":"protocol-neighbors","verified":false,"confidence":"medium","updatedAt":"2026-10-10T07:44:41.415Z","emptyReason":null},"items":[{"id":"8ebccd8e-3863-4187-8355-c3f14e1f9edf","entityType":"agent","canonicalPath":"/agent/iofficeai-aionui","slug":"iofficeai-aionui","name":"AionUi","description":"Free, local, open-source 24/7 Cowork app and OpenClaw for Gemini CLI, Claude Code, Codex, OpenCode, Qwen Code, Goose CLI, Auggie, and more | 🌟 Star if you like it!","url":"https://github.com/iOfficeAI/AionUi","homepage":"https://www.aionui.com","source":"GITHUB_REPOS","protocols":["MCP","OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-10-09T19:11:12.944Z","createdAt":"2026-02-25T03:38:16.584Z","downloads":null},{"id":"b917f68a-ebff-438e-84f8-3f4b2494c0bc","entityType":"agent","canonicalPath":"/agent/activepieces-activepieces","slug":"activepieces-activepieces","name":"activepieces","description":"AI Agents & MCPs & AI Workflow Automation • (~400 MCP servers for AI agents) • AI Automation / AI Agent with MCPs • AI Workflows & AI Agents • MCPs for AI Agents","url":"https://github.com/activepieces/activepieces","homepage":"https://www.activepieces.com","source":"GITHUB_REPOS","protocols":["OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-04-15T02:22:12.426Z","createdAt":"2026-02-25T03:38:12.412Z","downloads":null},{"id":"5cb26759-3a39-483f-94cf-276a98c13bb8","entityType":"agent","canonicalPath":"/agent/cherryhq-cherry-studio","slug":"cherryhq-cherry-studio","name":"cherry-studio","description":"AI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs","url":"https://github.com/CherryHQ/cherry-studio","homepage":"https://cherry-ai.com","source":"GITHUB_REPOS","protocols":["MCP","OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-04-11T14:38:40.986Z","createdAt":"2026-02-25T03:38:19.379Z","downloads":null},{"id":"6f6582d0-5d76-4f0f-b81d-86520247950b","entityType":"agent","canonicalPath":"/agent/copilotkit-copilotkit","slug":"copilotkit-copilotkit","name":"CopilotKit","description":"The Frontend for Agents & Generative UI. React + Angular","url":"https://github.com/CopilotKit/CopilotKit","homepage":"https://docs.copilotkit.ai","source":"GITHUB_REPOS","protocols":["OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-03-25T09:50:57.846Z","createdAt":"2026-02-25T03:39:14.617Z","downloads":null}],"links":{"hub":"/agent","source":"/agent/source/clawhub","protocols":[{"label":"OpenClaw","href":"/agent/protocol/openclew"}]}}}