
Brewtools:Ssh
- 6 installs
- 29 repo stars
- Updated August 5, 2026
- kochetkov-ma/claude-brewcode
Helps with ai & agent building tasks.
About
brewtools:ssh is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- brewtools:ssh
- AI & Agent Building
- AI-coding skill
Brewtools:Ssh by the numbers
- 6 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #12,824 of 16,544 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 6, 2026 (Skillselion catalog sync)
npx skills add https://github.com/kochetkov-ma/claude-brewcode --skill brewtoolssshAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 6 |
|---|---|
| repo stars | ★ 29 |
| Last updated | August 5, 2026 |
| Repository | kochetkov-ma/claude-brewcode ↗ |
What it does
Helps with ai & agent building tasks.
Files
SSH Server Management
Manage remote Linux servers — connect, configure, deploy, administer with safety gates and persistent config.
<instructions>
Robustness Rules (MANDATORY — all phases)
Fail-Fast
| Rule | Applies to |
|---|---|
| Every Bash call MUST end with `&& echo "OK ..." \ | \ |
On FAILED — stop phase, report error, do NOT retry same command blindly | ALL phases |
SSH commands MUST use -o ConnectTimeout=10 -o BatchMode=yes | ALL SSH calls |
| Max 2 retries per failed operation; after 2nd failure — report and stop | ALL phases |
| Non-zero script exit — read stderr, diagnose, fix root cause, retry ONCE | Scripts |
Loop Protection
| Rule | Limit |
|---|---|
| Phase 2 (Connection Setup) — max 3 key attempts, then ask user | 3 keys |
| Phase 2 → Phase 5 round-trips — if sent back to Phase 2 more than once, stop and report | 1 re-entry |
| Phase 5 (Execute) — max 5 SSH commands per invocation; if more needed, delegate to ssh-admin agent via Task | 5 commands |
| update-agent mode — max 3 servers per run; process first 3 and report remaining | 3 servers |
| AskUserQuestion — max 3 questions per phase; summarize missing info in one combined question | 3 per phase |
Timeouts
| Operation | Timeout | Action on timeout |
|---|---|---|
| SSH connection test | 10s (ConnectTimeout=10) | Report "Server unreachable", stop |
| server-discover.sh | 30s (timeout 30 bash ...) | Report partial results, continue |
| Any single SSH command | 60s (timeout 60 ssh ...) | Kill, report "Command timed out", ask user |
| Entire skill invocation | 15 SSH calls total max | Stop, report progress, suggest manual continuation |
Fallback Strategy
If a script fails and cannot be fixed: 1. Report exact error: script name, exit code, stderr 2. Attempt same operation manually (inline Bash) — scripts are helpers, not gatekeepers 3. If manual fallback also fails — report both attempts, ask user 4. Never silently swallow errors or continue with stale/missing data
| Failed script | Manual alternative |
|---|---|
| detect-mode.sh | Parse $ARGUMENTS yourself — keyword matching is simple |
| ssh-env-check.sh | Run ls ~/.ssh/id_* 2>/dev/null, ssh-add -l, cat ~/.ssh/config |
| server-discover.sh | Run individual SSH commands: uname -a, docker version, df -h |
| claude-local-ops.sh | Read/write CLAUDE.local.md directly with Read/Edit tools |
Error Reporting (MANDATORY)
On ANY failure — before stopping or asking user — output:
SCRIPT_ERROR: <script-name>
EXIT_CODE: <code>
STDERR: <error message>
PHASE: <current phase>
ACTION: <what was attempted>
FALLBACK: <what will be tried next OR "asking user">---
Phase 0: Mode Detection (MANDATORY FIRST STEP)
EXECUTE using Bash tool:
bash "${CLAUDE_SKILL_DIR}/scripts/detect-mode.sh" "$ARGUMENTS"Output format:
ARGS: [arguments received]
MODE: [detected mode]Use the MODE value and GOTO that mode section below.
| Keyword in args | MODE |
|---|---|
| setup, new server, add server | setup |
| connect to, ssh to, login | connect |
| configure, config, harden | configure |
| update agent, refresh agent, refresh | update-agent |
| (any other text) | execute |
| (empty, no servers configured) | setup |
| (empty, servers configured) | execute (prompt user) |
---
Phase 1: Environment & Config Check
Runs for ALL modes before branching.
EXECUTE using Bash tool:
bash "${CLAUDE_SKILL_DIR}/scripts/ssh-env-check.sh" && echo "OK env-check" || echo "FAILED env-check"STOP if FAILED -- fix SSH environment before continuing.
Parse output key=value pairs. Note available keys and ssh-agent status.
EXECUTE using Bash tool:
bash "${CLAUDE_SKILL_DIR}/scripts/claude-local-ops.sh" list 2>/dev/null || echo "NO_SERVERS"| Condition | Action |
|---|---|
NO_SERVERS AND mode=setup | GOTO Phase 2: Connection Setup |
NO_SERVERS AND mode=execute/connect | GOTO Phase 2 (need server first) |
1 server AND mode=connect/execute | Use as default, GOTO Phase 5 |
Multiple servers AND mode=connect/execute | AskUserQuestion: which server? Then GOTO Phase 5 |
mode=setup (servers exist) | GOTO Phase 2 (adding new server) |
mode=configure | AskUserQuestion: which server? Then GOTO Phase 5 |
mode=update-agent | GOTO Mode: update-agent |
---
Phase 2: Connection Setup
Step 1: Gather Connection Info
Use AskUserQuestion:
header: "SSH Server Setup"
question: "Provide connection details for the new server."Collect via follow-up questions if not in $ARGUMENTS:
- Host (IP or hostname) -- REQUIRED
- User (default: deploy) -- REQUIRED
- Port (default: 22) -- optional
- Server name (short alias, e.g., vps-main) -- REQUIRED
Step 2: Key Discovery & Auth
EXECUTE using Bash tool:
bash "${CLAUDE_SKILL_DIR}/scripts/ssh-env-check.sh" && echo "OK keys" || echo "FAILED keys"Parse available keys. Try connection with each key (ed25519 first, then rsa, then ecdsa):
EXECUTE using Bash tool:
ssh-keyscan -p PORT HOST >> ~/.ssh/known_hosts 2>/dev/null && echo "OK keyscan" || echo "FAILED keyscan"EXECUTE using Bash tool:
ssh -o BatchMode=yes -o ConnectTimeout=10 -p PORT USER@HOST echo "OK auth" 2>/dev/null || echo "FAILED auth"Step 3: If Key Auth Fails
Use AskUserQuestion:
header: "SSH Authentication"
question: "Key authentication failed. Choose auth method:"
options:
- label: "Password login (will set up key auth)"
description: "Connect with password, then install SSH key"
- label: "Specify key path"
description: "Provide path to an existing private key"
- label: "Cancel"
description: "Abort server setup"If password login:
1. Generate dedicated key:
EXECUTE using Bash tool:
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_SERVERNAME -N "" -C "claude@SERVERNAME" && echo "OK keygen" || echo "FAILED keygen"Replace SERVERNAME with the server name alias.
2. Instruct user to copy key manually:
Interactive command required. Run this in your terminal:
```
! ssh-copy-id -i ~/.ssh/id_ed25519_SERVERNAME.pub -p PORT USER@HOST
```
This requires password entry which Claude Code cannot do non-interactively.
3. After user confirms, verify:
EXECUTE using Bash tool:
ssh -o BatchMode=yes -o ConnectTimeout=10 -i ~/.ssh/id_ed25519_SERVERNAME -p PORT USER@HOST echo "OK key-auth" 2>/dev/null || echo "FAILED key-auth"STOP if FAILED -- key auth must work before proceeding.
Step 4: SSH Config Entry
EXECUTE using Bash tool:
grep -q "^Host SERVERNAME$" ~/.ssh/config 2>/dev/null && echo "EXISTS" || echo "NEW"If NEW, add config entry using Edit/Write to ~/.ssh/config:
Host SERVERNAME
HostName HOST
User USER
Port PORT
IdentityFile ~/.ssh/id_ed25519_SERVERNAME
StrictHostKeyChecking accept-newStep 5: Final Connection Test
EXECUTE using Bash tool:
ssh -o BatchMode=yes -o ConnectTimeout=10 SERVERNAME echo "OK connection" 2>/dev/null || echo "FAILED connection"STOP if FAILED -- connection must work before discovery.
---
Phase 3: Server Discovery
EXECUTE using Bash tool:
bash "${CLAUDE_SKILL_DIR}/scripts/server-discover.sh" "USER@HOST" PORT && echo "OK discovery" || echo "FAILED discovery"Replace USER@HOST and PORT with actual values (or SSH config alias).
Parse output key=value pairs. Key fields:
OS,KERNEL,ARCHDOCKER_VERSION,DOCKER_COMPOSEDISK_INFO(data disks, mount points)RUNNING_CONTAINERS,SERVICESCURRENT_USER,USER_GROUPS
---
Phase 4: Persist Config
Step 1: Update CLAUDE.local.md
EXECUTE using Bash tool:
bash "${CLAUDE_SKILL_DIR}/scripts/claude-local-ops.sh" add "SERVERNAME" "HOST" "USER" "PORT" "KEYPATH" && echo "OK add" || echo "FAILED add"EXECUTE using Bash tool:
bash "${CLAUDE_SKILL_DIR}/scripts/claude-local-ops.sh" update "SERVERNAME" "OS_VALUE" "KERNEL_VALUE" "DOCKER_VALUE" "DISK_VALUE" "WORKDIR_VALUE" && echo "OK update" || echo "FAILED update"Replace placeholders with discovered values from Phase 3.
Step 2: Gitignore
EXECUTE using Bash tool:
grep -q "CLAUDE.local.md" .gitignore 2>/dev/null && echo "EXISTS" || (echo "CLAUDE.local.md" >> .gitignore && echo "ADDED")Step 3: Generate ssh-admin Agent
EXECUTE using Bash tool:
cat "${CLAUDE_SKILL_DIR}/templates/ssh-admin-agent.md.template"Replace placeholders in template:
{{SERVER_INVENTORY}}-- server table from CLAUDE.local.md{{SERVER_DETAILS}}-- discovered OS/Docker/disk info per server{{LAST_UPDATED}}-- current ISO timestamp
Write result to .claude/agents/ssh-admin.md using Write tool.
Step 4: Default Server
If this is the first server, set as default automatically.
If multiple servers exist, use AskUserQuestion:
header: "Default Server"
question: "Set SERVERNAME as the default SSH server?"
options:
- label: "Yes"
- label: "No"If yes: EXECUTE using Bash tool:
bash "${CLAUDE_SKILL_DIR}/scripts/claude-local-ops.sh" set-default "SERVERNAME" && echo "OK default" || echo "FAILED default"---
Phase 5: Execute User Request
Step 1: Load Safety Rules
Read references/safety-rules.md from skill directory for command classification rules.
Step 2: Plan & Classify
Analyze $ARGUMENTS. Create execution plan:
| Step | Command(s) | Classification | Confirmation |
|---|---|---|---|
| 1 | ... | READ/CREATE/MODIFY/SERVICE/DELETE/PRIVILEGE | free/confirm |
Step 3: Confirmation Gate
For MODIFY/SERVICE commands -- use AskUserQuestion:
header: "SSH Action Confirmation"
question: "About to execute on SERVER:\n\n[command list]\n\nProceed?"
options:
- label: "Yes, execute"
- label: "Cancel"For DELETE/PRIVILEGE commands -- use AskUserQuestion with explicit warning:
header: "DESTRUCTIVE SSH Action"
question: "WARNING: About to execute DESTRUCTIVE commands on SERVER:\n\n[command list]\n\nThis cannot be undone. Proceed?"
options:
- label: "Yes, I understand the risks"
- label: "Cancel"For READ/CREATE commands -- execute freely, no confirmation needed.
Step 4: Execute
For complex multi-step operations, delegate to ssh-admin agent via Task tool:
| Parameter | Value |
|---|---|
subagent_type | ssh-admin |
prompt | [Detailed task description with server info, safety classification, and specific commands to run] |
For simple single-command operations, execute directly:
EXECUTE using Bash tool:
ssh SERVERNAME "COMMAND" && echo "OK" || echo "FAILED"Step 5: Docker Auth (if needed)
If task involves Docker registry operations, read references/docker-auth-flow.md for auth patterns.
Use AskUserQuestion for registry credentials -- NEVER hardcode tokens.
---
Phase 6: Session Report
| Field | Value |
|---|---|
| Server | SERVERNAME (HOST) |
| Mode | [detected mode] |
| Actions | [list of actions performed] |
| Changes | [list of changes made on server] |
| Status | success / partial / failed |
After execution: if new info discovered, update CLAUDE.local.md; if server state changed significantly, update ssh-admin agent.
bash "${CLAUDE_SKILL_DIR}/scripts/claude-local-ops.sh" update "SERVERNAME" ...---
Mode: update-agent
Re-discover all configured servers and refresh the ssh-admin agent.
Step 1: List Servers
EXECUTE using Bash tool:
bash "${CLAUDE_SKILL_DIR}/scripts/claude-local-ops.sh" listStep 2: Re-discover Each Server
EXECUTE using Bash tool:
bash "${CLAUDE_SKILL_DIR}/scripts/server-discover.sh" "USER@HOST" PORT && echo "OK discovery" || echo "FAILED discovery"Step 3: Update Config & Agent
Update CLAUDE.local.md with fresh data for each server. Regenerate .claude/agents/ssh-admin.md from template with updated inventory. Set {{LAST_UPDATED}} to current timestamp. Report what changed since last update.
</instructions>
---
Output Format
# SSH [MODE]
## Detection
| Field | Value |
|-------|-------|
| Arguments | `$ARGUMENTS` |
| Mode | `[detected mode]` |
## Environment
| Component | Status |
|-----------|--------|
| SSH keys | [types found] |
| ssh-agent | [running/stopped] |
| SSH config | [exists/missing] |
| Servers configured | [N] |
## Server: [NAME]
| Property | Value |
|----------|-------|
| Host | [IP/hostname] |
| OS | [distribution] |
| Docker | [version] |
| Status | [connected/failed] |
## Actions Taken
- [action 1]
- [action 2]
## Status
[success / partial / failed]Docker Registry Authentication Patterns
Reference for authenticating to container registries on remote servers.
GHCR (GitHub Container Registry)
Login
echo "$GITHUB_TOKEN" | docker login ghcr.io -u USERNAME --password-stdin| Parameter | Source | Notes |
|---|---|---|
GITHUB_TOKEN | GitHub PAT (classic) or fine-grained | Scope: read:packages (pull), write:packages (push) |
USERNAME | GitHub username | Case-sensitive |
Pull Pattern
docker pull ghcr.io/OWNER/IMAGE:TAGToken Creation
1. GitHub Settings > Developer Settings > Personal Access Tokens 2. Classic token: select read:packages, write:packages 3. Fine-grained: select repository, Packages: Read (or Read+Write)
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
denied: denied | Token missing read:packages | Regenerate with correct scope |
unauthorized: unauthenticated | Not logged in | Run docker login ghcr.io |
manifest unknown | Wrong image name/tag | Check ghcr.io/OWNER/IMAGE:TAG |
DockerHub
Login
echo "$DOCKER_TOKEN" | docker login -u USERNAME --password-stdin| Parameter | Source | Notes |
|---|---|---|
DOCKER_TOKEN | DockerHub Access Token | Hub > Account Settings > Security > Access Tokens |
USERNAME | DockerHub username |
Rate Limits
| Auth State | Limit |
|---|---|
| Anonymous | 100 pulls / 6h per IP |
| Authenticated (free) | 200 pulls / 6h |
| Pro/Team | 5000 pulls / day |
Multi-Registry Setup
When server needs access to multiple registries:
# GHCR
echo "$GH_TOKEN" | docker login ghcr.io -u GH_USER --password-stdin
# DockerHub
echo "$DH_TOKEN" | docker login -u DH_USER --password-stdin
# Custom registry
echo "$REG_TOKEN" | docker login registry.example.com -u REG_USER --password-stdinAll credentials stored in ~/.docker/config.json:
{
"auths": {
"ghcr.io": { "auth": "base64..." },
"https://index.docker.io/v1/": { "auth": "base64..." },
"registry.example.com": { "auth": "base64..." }
}
}Credential Helpers
For production servers, use credential helpers instead of plain config:
{
"credHelpers": {
"ghcr.io": "pass",
"registry.example.com": "secretservice"
}
}Token Refresh
Check if token is valid
docker login ghcr.io -u USERNAME --password-stdin <<< "$TOKEN" 2>&1 | grep -q "Login Succeeded"Automated refresh in CI/deploy scripts
# Check auth, re-login if expired
docker pull ghcr.io/OWNER/IMAGE:TAG 2>&1 | grep -q "unauthorized" && \
echo "$FRESH_TOKEN" | docker login ghcr.io -u USERNAME --password-stdinSecurity Rules
| Rule | Details |
|---|---|
| NEVER hardcode tokens | Use env vars, secrets manager, or AskUserQuestion |
| NEVER commit .docker/config.json | Contains base64 credentials |
| Rotate tokens regularly | 90-day max for production |
| Use read-only tokens for pull | Minimize blast radius |
| Credential helpers | Preferred over plain JSON on production |
SSH Command Safety Classification
Reference for command classification and confirmation gates.
Classification Levels
| Level | Gate | Description |
|---|---|---|
| READ | free | Observe system state, no changes |
| CREATE | free | Create new resources, no overwrites |
| MODIFY | confirm | Change existing files, configs, permissions |
| SERVICE | confirm | Start/stop/restart services, containers |
| DELETE | always confirm | Remove files, containers, volumes, data |
| PRIVILEGE | always confirm | Escalate permissions, change security |
READ Commands (free)
| Category | Commands |
|---|---|
| Filesystem | ls, cat, head, tail, less, find, tree, stat, file, wc |
| System | uname, hostname, uptime, whoami, id, groups, env, printenv |
| Resources | df, du, free, top, htop, vmstat, iostat, lscpu, lsmem |
| Network | ip addr, ip route, ss, netstat, ping, traceroute, dig, nslookup, curl -I |
| Processes | ps, pgrep, lsof |
| Docker | docker ps, docker images, docker logs, docker inspect, docker stats, docker network ls, docker volume ls, docker compose ps |
| Services | systemctl status, systemctl list-units, systemctl is-active, journalctl |
| Logs | journalctl, tail -f /var/log/*, dmesg |
CREATE Commands (free)
| Category | Commands |
|---|---|
| Filesystem | mkdir, touch, tee (new file only) |
| Docker | docker pull, docker network create, docker volume create, docker build |
| Users | (none -- all user ops are PRIVILEGE) |
MODIFY Commands (confirm)
| Category | Commands | Risk |
|---|---|---|
| Permissions | chmod, chown, chgrp | Access changes |
| Files | sed -i, cp (overwrite), mv, tee (existing file) | Data modification |
| Docker | docker tag, docker compose build | Image changes |
| Services | systemctl enable, systemctl disable | Boot behavior |
| Config | Edit any file in /etc/, crontab -e | System config |
| Network | ip link set, DNS config changes | Connectivity |
SERVICE Commands (confirm)
| Category | Commands | Risk |
|---|---|---|
| Systemd | systemctl restart, systemctl stop, systemctl start, systemctl reload | Service disruption |
| Docker | docker compose up, docker compose down, docker compose restart, docker stop, docker start, docker restart | Container disruption |
| Web | nginx -s reload, nginx -s stop, caddy reload, caddy stop | Web service disruption |
| Process | kill, killall, pkill | Process termination |
DELETE Commands (always confirm)
| Category | Commands | Risk |
|---|---|---|
| Files | rm, rm -rf, rmdir, shred | Data loss |
| Docker | docker rm, docker rmi, docker volume rm, docker network rm, docker system prune, docker compose down -v | Container/data loss |
| Database | DROP TABLE, DROP DATABASE, TRUNCATE | Data loss |
| Users | userdel, groupdel | Access loss |
| Cleanup | apt autoremove, apt purge | Package removal |
PRIVILEGE Commands (always confirm)
| Category | Commands | Risk |
|---|---|---|
| Escalation | sudo, su, sudo -i, sudo su | Full access |
| Security | visudo, passwd, chpasswd | Auth changes |
| Firewall | ufw allow, ufw deny, ufw delete, iptables, nftables | Network exposure |
| Users | useradd, usermod, adduser, gpasswd | Access control |
| Mount | mount, umount, fdisk, mkfs | Disk operations |
| SSH | sshd config changes, authorized_keys edits | Remote access |
Compound Command Rules
| Pattern | Classification | Why |
|---|---|---|
sudo + any command | PRIVILEGE (overrides cmd level) | Escalation always confirmed |
| Pipeline: `cmd1 \ | cmd2` | Highest of both |
&& chain | Highest of all | All commands will execute |
Redirect > to existing file | MODIFY | Overwrites content |
Redirect >> to new file | CREATE | Appends/creates |
| `curl \ | bash` | PRIVILEGE |
wget && chmod +x && ./ | PRIVILEGE | Download and execute |
Confirmation Message Format
MODIFY/SERVICE
About to execute on [SERVER]:
[command 1]
[command 2]
Classification: MODIFY/SERVICE
Proceed?DELETE/PRIVILEGE
WARNING: DESTRUCTIVE action on [SERVER]:
[command 1] -- [what it deletes/changes]
Classification: DELETE/PRIVILEGE
This cannot be undone.
Proceed?Emergency Stop
If any command returns unexpected output suggesting:
- Wrong server (hostname mismatch)
- Production environment (when expecting staging)
- Root filesystem nearly full (<5% free)
- Unexpected running services
STOP immediately. Report findings. Ask user to confirm before continuing.
SSH Best Practices
Reference for key management, configuration, and hardening.
Key Types
| Type | Algorithm | Recommended | Notes |
|---|---|---|---|
| ed25519 | EdDSA | Yes (preferred) | Fastest, smallest, most secure |
| ecdsa | ECDSA | Acceptable | NIST curves, some concerns |
| rsa | RSA | Fallback only | Minimum 4096 bits, slow |
| dsa | DSA | Never | Deprecated, insecure |
Key Generation
# Preferred: ed25519
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_SERVERNAME -C "user@purpose"
# Fallback: RSA 4096
ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa_SERVERNAME -C "user@purpose"Key Naming Convention
| Pattern | Example | Use |
|---|---|---|
id_ed25519_{server} | id_ed25519_vps-main | Per-server key |
id_ed25519_{purpose} | id_ed25519_deploy | Per-purpose key |
id_ed25519_{org}_{env} | id_ed25519_acme_prod | Per-org per-env |
SSH Config Patterns
Basic Host Block
Host vps-main
HostName 173.249.57.235
User deploy
Port 22
IdentityFile ~/.ssh/id_ed25519_vps-main
StrictHostKeyChecking accept-newJump Host (ProxyJump)
Host bastion
HostName bastion.example.com
User admin
IdentityFile ~/.ssh/id_ed25519_bastion
Host internal-server
HostName 10.0.1.50
User deploy
ProxyJump bastion
IdentityFile ~/.ssh/id_ed25519_internalWildcard Patterns
Host *.prod.example.com
User deploy
IdentityFile ~/.ssh/id_ed25519_prod
LogLevel ERROR
Host *.staging.example.com
User admin
IdentityFile ~/.ssh/id_ed25519_stagingConnection Optimization
Host *
ServerAliveInterval 60
ServerAliveCountMax 3
ControlMaster auto
ControlPath ~/.ssh/sockets/%r@%h-%p
ControlPersist 600
AddKeysToAgent yes
IdentitiesOnly yesCreate sockets dir: mkdir -p ~/.ssh/socketsServer Hardening (sshd_config)
Essential Settings
| Setting | Value | Why |
|---|---|---|
PermitRootLogin | no | Prevent root SSH access |
PasswordAuthentication | no | Force key-only auth |
PubkeyAuthentication | yes | Enable key auth |
MaxAuthTries | 3 | Limit brute force |
PermitEmptyPasswords | no | Block empty passwords |
X11Forwarding | no | Disable unless needed |
AllowTcpForwarding | no | Disable unless needed |
UsePAM | yes | System auth integration |
Restrict Users
AllowUsers deploy admin
AllowGroups ssh-users
DenyUsers rootPort Change
Port 2222Update firewall: ufw allow 2222/tcp && ufw deny 22/tcpssh-agent
When to Use Forwarding
| Scenario | Forward? | Why |
|---|---|---|
| Deploy from CI to server | No | Use deploy keys |
| Jump through bastion | Yes | Need key on intermediate |
| Interactive dev session | Maybe | Convenience vs security |
| Production servers | Never | Attack surface |
Agent Forwarding Risks
- Compromised intermediate host can use your agent
- Any user with root on intermediate can hijack socket
- Mitigation:
ssh -J bastion target(ProxyJump) instead of ForwardAgent
Start Agent
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519_vps-mainknown_hosts Management
Initial Setup
# Scan and add host key
ssh-keyscan -p PORT HOST >> ~/.ssh/known_hosts 2>/dev/null
# Hash known_hosts for privacy
ssh-keygen -H -f ~/.ssh/known_hostsConfig Option
HashKnownHosts yes
StrictHostKeyChecking accept-new| Setting | Behavior |
|---|---|
accept-new | Auto-accept new hosts, reject changed keys |
yes | Reject unknown hosts (most secure) |
no | Accept everything (insecure) |
ask | Interactive prompt (default) |
Key Rotation
When server key changes legitimately:
ssh-keygen -R HOST
ssh-keyscan -p PORT HOST >> ~/.ssh/known_hostsFile Permissions
| Path | Permission | Numeric |
|---|---|---|
~/.ssh/ | drwx------ | 700 |
~/.ssh/config | -rw------- | 600 |
~/.ssh/id_* (private) | -rw------- | 600 |
~/.ssh/id_*.pub | -rw-r--r-- | 644 |
~/.ssh/known_hosts | -rw-r--r-- | 644 |
~/.ssh/authorized_keys | -rw------- | 600 |
Fix Permissions
chmod 700 ~/.ssh
chmod 600 ~/.ssh/config ~/.ssh/id_* ~/.ssh/authorized_keys
chmod 644 ~/.ssh/*.pub ~/.ssh/known_hostsConnection Troubleshooting
| Symptom | Debug Command | Common Fix |
|---|---|---|
| Permission denied | ssh -vvv user@host | Check key permissions, authorized_keys |
| Connection timeout | ssh -o ConnectTimeout=5 user@host | Check firewall, port, IP |
| Host key changed | ssh-keygen -R host | Re-scan with ssh-keyscan |
| Agent forwarding fails | ssh-add -l | Add key to agent |
| Too many auth failures | ssh -o IdentitiesOnly=yes -i key user@host | Specify exact key |
#!/bin/bash
set -euo pipefail
# Manage CLAUDE.local.md server entries
# Usage: claude-local-ops.sh <subcommand> [args...]
# Subcommands: read, add, update, list, set-default
LOCAL_FILE="CLAUDE.local.md"
SUBCMD="${1:?Usage: claude-local-ops.sh <read|add|update|list|set-default> [args...]}"
shift
# Initialize file if missing
init_file() {
if [[ ! -f "$LOCAL_FILE" ]]; then
cat > "$LOCAL_FILE" << 'HEREDOC'
# Local Configuration
> This file is gitignored. Do not commit.
## SSH Servers
| Name | Host | User | Port | Key | Default |
|------|------|------|------|-----|---------|
> Connect via: `/brewtools:ssh connect to <name>` or `/brewtools:ssh <task description>`
HEREDOC
fi
}
# Check if line is a server data row (not header, not separator, not other tables)
is_server_row() {
local line="$1"
[[ "$line" == "| "* ]] && \
[[ "$line" != "| Name "* ]] && \
[[ "$line" != "| -"* ]] && \
[[ "$line" != "| Property "* ]] && \
[[ "$line" != "|---"* ]] && \
[[ "$line" != "|-"* ]]
}
# Extract field from pipe-delimited row by position (1-based)
get_field() {
echo "$1" | awk -F'|' -v n="$2" '{gsub(/^[ \t]+|[ \t]+$/, "", $n); print $n}'
}
# Parse server rows only from SSH Servers section (between header and > Connect)
get_server_rows() {
local in_table=false
while IFS= read -r line; do
if [[ "$line" == "| Name |"* ]]; then
in_table=true
continue
fi
if [[ "$in_table" == true ]]; then
if [[ "$line" == "|---"* ]]; then
continue
fi
if [[ "$line" == "| "* ]]; then
echo "$line"
else
break
fi
fi
done < "$LOCAL_FILE"
}
case "$SUBCMD" in
read)
if [[ ! -f "$LOCAL_FILE" ]]; then
echo "FILE=missing"
exit 0
fi
echo "FILE=exists"
get_server_rows | while IFS= read -r row; do
name=$(get_field "$row" 2)
host=$(get_field "$row" 3)
user=$(get_field "$row" 4)
port=$(get_field "$row" 5)
key=$(get_field "$row" 6)
default=$(get_field "$row" 7)
[[ -z "$name" ]] && continue
echo "SERVER=$name"
echo "${name}_HOST=$host"
echo "${name}_USER=$user"
echo "${name}_PORT=$port"
echo "${name}_KEY=$key"
echo "${name}_DEFAULT=$default"
done
;;
add)
NAME="${1:?add requires: name host user port key}"
HOST="${2:?add requires: host}"
USER="${3:?add requires: user}"
PORT="${4:-22}"
KEY="${5:-~/.ssh/id_ed25519_$NAME}"
init_file
if grep -q "| $NAME |" "$LOCAL_FILE" 2>/dev/null; then
echo "ERROR: Server '$NAME' already exists. Use 'update' to modify."
exit 1
fi
# Count existing server rows
EXISTING=$(get_server_rows | wc -l | tr -d ' ')
if [[ "$EXISTING" -eq 0 ]]; then
DEFAULT_FLAG="*"
else
DEFAULT_FLAG=""
fi
ROW="| $NAME | $HOST | $USER | $PORT | $KEY | $DEFAULT_FLAG |"
# Insert row after table separator, before "> Connect" line
TMPF=$(mktemp)
AFTER_SEPARATOR=false
INSERTED=false
while IFS= read -r line; do
# Detect table separator
if [[ "$line" == "|---"* ]] && [[ "$AFTER_SEPARATOR" == false ]]; then
echo "$line" >> "$TMPF"
AFTER_SEPARATOR=true
continue
fi
# Insert before "> Connect" or blank line AFTER separator
if [[ "$AFTER_SEPARATOR" == true ]] && [[ "$INSERTED" == false ]]; then
if [[ "$line" == "> Connect"* ]] || [[ -z "$line" ]]; then
echo "$ROW" >> "$TMPF"
INSERTED=true
# Write the current line too
echo "$line" >> "$TMPF"
continue
fi
fi
echo "$line" >> "$TMPF"
done < "$LOCAL_FILE"
if [[ "$INSERTED" == false ]]; then
echo "$ROW" >> "$TMPF"
fi
mv "$TMPF" "$LOCAL_FILE"
echo "ADDED=$NAME"
echo "DEFAULT=$DEFAULT_FLAG"
;;
update)
NAME="${1:?update requires: name os kernel docker disk workdir}"
OS="${2:-unknown}"
KERNEL="${3:-unknown}"
DOCKER="${4:-not installed}"
DISK="${5:-unknown}"
WORKDIR="${6:-/opt}"
if [[ ! -f "$LOCAL_FILE" ]]; then
echo "ERROR: $LOCAL_FILE not found"
exit 1
fi
# Remove existing server details section
SECTION_START="## Server: $NAME"
if grep -q "$SECTION_START" "$LOCAL_FILE"; then
TMPF=$(mktemp)
IN_SECTION=false
while IFS= read -r line; do
if [[ "$line" == "$SECTION_START" ]]; then
IN_SECTION=true
continue
fi
if [[ "$IN_SECTION" == true ]] && [[ "$line" == "## "* ]]; then
IN_SECTION=false
fi
if [[ "$IN_SECTION" == false ]]; then
echo "$line" >> "$TMPF"
fi
done < "$LOCAL_FILE"
mv "$TMPF" "$LOCAL_FILE"
fi
cat >> "$LOCAL_FILE" << HEREDOC
## Server: $NAME
| Property | Value |
|----------|-------|
| OS | $OS |
| Kernel | $KERNEL |
| Docker | $DOCKER |
| Data disk | $DISK |
| Working dir | $WORKDIR |
HEREDOC
echo "UPDATED=$NAME"
;;
list)
if [[ ! -f "$LOCAL_FILE" ]]; then
echo "NO_SERVERS"
exit 0
fi
SERVERS=()
DEFAULTS=()
while IFS= read -r row; do
name=$(get_field "$row" 2)
default=$(get_field "$row" 7)
[[ -z "$name" ]] && continue
SERVERS+=("$name")
DEFAULTS+=("$default")
done < <(get_server_rows)
if [[ ${#SERVERS[@]} -eq 0 ]]; then
echo "NO_SERVERS"
else
echo "SERVER_COUNT=${#SERVERS[@]}"
for i in "${!SERVERS[@]}"; do
if [[ "${DEFAULTS[$i]}" == "*" ]]; then
echo "SERVER=${SERVERS[$i]} (default)"
else
echo "SERVER=${SERVERS[$i]}"
fi
done
fi
;;
set-default)
NAME="${1:?set-default requires: name}"
if [[ ! -f "$LOCAL_FILE" ]]; then
echo "ERROR: $LOCAL_FILE not found"
exit 1
fi
if ! grep -q "| $NAME |" "$LOCAL_FILE"; then
echo "ERROR: Server '$NAME' not found"
exit 1
fi
# Rewrite: clear defaults in SSH Servers table, set new one
TMPF=$(mktemp)
IN_TABLE=false
while IFS= read -r line; do
if [[ "$line" == "| Name |"* ]]; then
IN_TABLE=true
echo "$line" >> "$TMPF"
continue
fi
if [[ "$IN_TABLE" == true ]] && [[ "$line" == "|---"* ]]; then
echo "$line" >> "$TMPF"
continue
fi
if [[ "$IN_TABLE" == true ]] && [[ "$line" == "| "* ]]; then
# Parse columns, rewrite Default field
srv_name=$(get_field "$line" 2)
srv_host=$(get_field "$line" 3)
srv_user=$(get_field "$line" 4)
srv_port=$(get_field "$line" 5)
srv_key=$(get_field "$line" 6)
if [[ "$srv_name" == "$NAME" ]]; then
echo "| $srv_name | $srv_host | $srv_user | $srv_port | $srv_key | * |" >> "$TMPF"
else
echo "| $srv_name | $srv_host | $srv_user | $srv_port | $srv_key | |" >> "$TMPF"
fi
continue
fi
if [[ "$IN_TABLE" == true ]]; then
IN_TABLE=false
fi
echo "$line" >> "$TMPF"
done < "$LOCAL_FILE"
mv "$TMPF" "$LOCAL_FILE"
echo "DEFAULT=$NAME"
;;
*)
echo "ERROR: Unknown subcommand '$SUBCMD'"
echo "Usage: claude-local-ops.sh <read|add|update|list|set-default> [args...]"
exit 1
;;
esac
#!/bin/bash
set -euo pipefail
# Detect SSH skill mode from arguments
# Usage: detect-mode.sh "$ARGUMENTS"
# Output: ARGS and MODE for parsing
ARGS="${1:-}"
# Trim whitespace
ARGS=$(echo "$ARGS" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
ARGS_LOWER=$(echo "$ARGS" | tr '[:upper:]' '[:lower:]')
echo "ARGS: [$ARGS]"
MODE=""
# Check keywords (order matters - first match wins)
if [[ "$ARGS_LOWER" =~ (set[[:space:]]*up|setup|new[[:space:]]+server|add[[:space:]]+server) ]]; then
MODE="setup"
elif [[ "$ARGS_LOWER" =~ (connect\ to|ssh\ to|login\ to|connect) ]]; then
MODE="connect"
elif [[ "$ARGS_LOWER" =~ (configure|config|harden) ]]; then
MODE="configure"
elif [[ "$ARGS_LOWER" =~ (update\ agent|refresh\ agent|refresh) ]]; then
MODE="update-agent"
elif [[ -z "$ARGS" ]]; then
# No arguments - check if servers are configured
if [[ -f "CLAUDE.local.md" ]] && grep -q "^|.*|.*|.*|.*|.*|" "CLAUDE.local.md" 2>/dev/null; then
MODE="execute"
else
MODE="setup"
fi
else
# Has args but no keyword match - treat as execute (run command/task on server)
MODE="execute"
fi
echo "MODE: $MODE"
#!/bin/bash
set -euo pipefail
# Discover remote server: OS, disks, Docker, services, users
# Usage: server-discover.sh "user@host" [port]
# Output: structured key=value pairs
CONNECTION="${1:?Usage: server-discover.sh user@host [port]}"
PORT="${2:-22}"
SSH_OPTS="-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -p $PORT"
# Fail-fast: verify connectivity before full discovery
if ! ssh $SSH_OPTS "$CONNECTION" true 2>/dev/null; then
echo "ERROR: Cannot connect to $CONNECTION:$PORT (timeout or auth failure)" >&2
exit 1
fi
echo "=== Server Discovery: $CONNECTION ==="
# Helper: run SSH command, return output or fallback
ssh_cmd() {
local cmd="$1"
local fallback="${2:-n/a}"
local result
result=$(ssh $SSH_OPTS "$CONNECTION" "$cmd" 2>/dev/null) || result="$fallback"
echo "$result"
}
# OS info
echo "=== OS Info ==="
OS_PRETTY=$(ssh_cmd "cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d= -f2 | tr -d '\"'" "unknown")
echo "OS=$OS_PRETTY"
KERNEL=$(ssh_cmd "uname -r" "unknown")
echo "KERNEL=$KERNEL"
ARCH=$(ssh_cmd "uname -m" "unknown")
echo "ARCH=$ARCH"
HOSTNAME=$(ssh_cmd "hostname" "unknown")
echo "HOSTNAME=$HOSTNAME"
UPTIME=$(ssh_cmd "uptime -p 2>/dev/null || uptime" "unknown")
echo "UPTIME=$UPTIME"
# Memory
echo "=== Memory ==="
MEM_TOTAL=$(ssh_cmd "free -h 2>/dev/null | awk '/^Mem:/{print \$2}'" "unknown")
echo "MEM_TOTAL=$MEM_TOTAL"
MEM_USED=$(ssh_cmd "free -h 2>/dev/null | awk '/^Mem:/{print \$3}'" "unknown")
echo "MEM_USED=$MEM_USED"
# Disks
echo "=== Disks ==="
DISK_INFO=$(ssh_cmd "df -h --output=source,size,used,avail,pcent,target 2>/dev/null | grep -E '^/dev/' || df -h | grep -E '^/dev/'" "unknown")
echo "DISK_INFO<<EOF"
echo "$DISK_INFO"
echo "EOF"
# Docker
echo "=== Docker ==="
DOCKER_VERSION=$(ssh_cmd "docker version --format '{{.Server.Version}}' 2>/dev/null" "not_installed")
echo "DOCKER_VERSION=$DOCKER_VERSION"
DOCKER_COMPOSE=$(ssh_cmd "docker compose version --short 2>/dev/null || docker-compose --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+'" "not_installed")
echo "DOCKER_COMPOSE=$DOCKER_COMPOSE"
if [[ "$DOCKER_VERSION" != "not_installed" ]]; then
RUNNING_CONTAINERS=$(ssh_cmd "docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' 2>/dev/null" "none")
echo "RUNNING_CONTAINERS<<EOF"
echo "$RUNNING_CONTAINERS"
echo "EOF"
DOCKER_IMAGES=$(ssh_cmd "docker images --format '{{.Repository}}:{{.Tag}}\t{{.Size}}' 2>/dev/null | head -20" "none")
echo "DOCKER_IMAGES<<EOF"
echo "$DOCKER_IMAGES"
echo "EOF"
else
echo "RUNNING_CONTAINERS=n/a"
echo "DOCKER_IMAGES=n/a"
fi
# Services (systemd)
echo "=== Services ==="
SERVICES=$(ssh_cmd "systemctl list-units --type=service --state=running --no-pager --no-legend 2>/dev/null | awk '{print \$1}' | head -30" "unknown")
echo "SERVICES<<EOF"
echo "$SERVICES"
echo "EOF"
# Listening ports
echo "=== Ports ==="
PORTS=$(ssh_cmd "ss -tlnp 2>/dev/null | tail -n +2 | head -20 || netstat -tlnp 2>/dev/null | tail -n +3 | head -20" "unknown")
echo "PORTS<<EOF"
echo "$PORTS"
echo "EOF"
# Current user info
echo "=== User ==="
CURRENT_USER=$(ssh_cmd "whoami" "unknown")
echo "CURRENT_USER=$CURRENT_USER"
USER_GROUPS=$(ssh_cmd "id" "unknown")
echo "USER_GROUPS=$USER_GROUPS"
SUDO_ACCESS=$(ssh_cmd "sudo -n true 2>/dev/null && echo 'yes' || echo 'no'" "unknown")
echo "SUDO_ACCESS=$SUDO_ACCESS"
# Common working directories
echo "=== Working Dirs ==="
for dir in /opt /srv /home /data /var/www; do
EXISTS=$(ssh_cmd "test -d $dir && ls -la $dir 2>/dev/null | head -5" "not_found")
if [[ "$EXISTS" != "not_found" ]]; then
echo "DIR_${dir//\//_}=exists"
fi
done
echo "=== Discovery Complete ==="
#!/bin/bash
set -euo pipefail
# Check SSH environment: keys, config, ssh-agent
# No args needed
# Output: structured key=value pairs
echo "=== SSH Environment Check ==="
# Check ~/.ssh/ directory
if [[ -d "$HOME/.ssh" ]]; then
echo "SSH_DIR=exists"
SSH_DIR_PERMS=$(stat -f "%Lp" "$HOME/.ssh" 2>/dev/null || stat -c "%a" "$HOME/.ssh" 2>/dev/null || echo "unknown")
echo "SSH_DIR_PERMS=$SSH_DIR_PERMS"
else
echo "SSH_DIR=missing"
echo "SSH_DIR_PERMS=n/a"
# Create with correct permissions
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
echo "SSH_DIR_CREATED=true"
fi
# List available keys
echo "=== Available Keys ==="
ED25519_KEYS=()
RSA_KEYS=()
ECDSA_KEYS=()
for keyfile in "$HOME"/.ssh/id_*; do
[[ -f "$keyfile" ]] || continue
# Skip .pub files
[[ "$keyfile" == *.pub ]] && continue
basename_key=$(basename "$keyfile")
if [[ "$basename_key" == id_ed25519* ]]; then
ED25519_KEYS+=("$keyfile")
echo "KEY_ED25519=$keyfile"
elif [[ "$basename_key" == id_rsa* ]]; then
RSA_KEYS+=("$keyfile")
echo "KEY_RSA=$keyfile"
elif [[ "$basename_key" == id_ecdsa* ]]; then
ECDSA_KEYS+=("$keyfile")
echo "KEY_ECDSA=$keyfile"
fi
done
echo "ED25519_COUNT=${#ED25519_KEYS[@]}"
echo "RSA_COUNT=${#RSA_KEYS[@]}"
echo "ECDSA_COUNT=${#ECDSA_KEYS[@]}"
TOTAL_KEYS=$(( ${#ED25519_KEYS[@]} + ${#RSA_KEYS[@]} + ${#ECDSA_KEYS[@]} ))
echo "TOTAL_KEYS=$TOTAL_KEYS"
# Check ssh-agent
echo "=== SSH Agent ==="
if ssh-add -l &>/dev/null; then
echo "SSH_AGENT=running"
LOADED_KEYS=$(ssh-add -l 2>/dev/null | wc -l | tr -d ' ')
echo "AGENT_KEYS_LOADED=$LOADED_KEYS"
elif [[ $? -eq 1 ]]; then
# Agent running but no keys loaded
echo "SSH_AGENT=running"
echo "AGENT_KEYS_LOADED=0"
else
echo "SSH_AGENT=not_running"
echo "AGENT_KEYS_LOADED=0"
fi
# Check SSH config
echo "=== SSH Config ==="
if [[ -f "$HOME/.ssh/config" ]]; then
echo "SSH_CONFIG=exists"
HOST_COUNT=$(grep -c "^Host " "$HOME/.ssh/config" 2>/dev/null || echo "0")
echo "SSH_CONFIG_HOSTS=$HOST_COUNT"
else
echo "SSH_CONFIG=missing"
echo "SSH_CONFIG_HOSTS=0"
fi
# Check known_hosts
echo "=== Known Hosts ==="
if [[ -f "$HOME/.ssh/known_hosts" ]]; then
echo "KNOWN_HOSTS=exists"
KH_COUNT=$(wc -l < "$HOME/.ssh/known_hosts" | tr -d ' ')
echo "KNOWN_HOSTS_ENTRIES=$KH_COUNT"
else
echo "KNOWN_HOSTS=missing"
echo "KNOWN_HOSTS_ENTRIES=0"
fi
echo "=== Check Complete ==="
---
name: ssh-admin
model: opus
# description MUST be <=100 chars, single line
description: "SSH server admin with live inventory. Runs remote commands with safety classification."
allowed-tools: Read, Write, Edit, Bash, Grep, Glob, AskUserQuestion
---
# SSH Admin Agent
> Last updated: {{LAST_UPDATED}}
## Server Inventory
{{SERVER_INVENTORY}}
## Server Details
{{SERVER_DETAILS}}
## Instructions
You are an SSH server administration agent. Execute tasks on remote Linux servers using SSH.
### Connection
Use SSH config aliases when available. Connection format: `ssh ALIAS "command"`
If no alias, use: `ssh -o BatchMode=yes -o ConnectTimeout=15 -p PORT USER@HOST "command"`
### Safety Classification
Before executing any command, classify it:
| Level | Gate | Action |
|-------|------|--------|
| **READ** | free | Execute immediately |
| **CREATE** | free | Execute immediately |
| **MODIFY** | confirm | AskUserQuestion before executing |
| **SERVICE** | confirm | AskUserQuestion before executing |
| **DELETE** | always confirm | AskUserQuestion with explicit warning |
| **PRIVILEGE** | always confirm | AskUserQuestion with explicit warning |
**READ commands** (free): `ls`, `cat`, `head`, `tail`, `df`, `du`, `free`, `top`, `ps`, `docker ps`, `docker logs`, `systemctl status`, `journalctl`, `ip addr`, `ss`, `uname`
**CREATE commands** (free): `mkdir`, `touch`, `docker pull`, `docker network create`, `docker volume create`
**MODIFY commands** (confirm): `chmod`, `chown`, `sed -i`, `cp` (overwrite), `mv`, `systemctl enable/disable`, config edits
**SERVICE commands** (confirm): `systemctl restart/stop/start`, `docker compose up/down/restart`, `docker stop/start`, `nginx -s reload`
**DELETE commands** (always confirm): `rm`, `docker rm`, `docker rmi`, `docker volume rm`, `docker system prune`
**PRIVILEGE commands** (always confirm): `sudo`, `ufw`, `iptables`, `passwd`, `useradd`, `mount`
### Compound Rules
- `sudo` + any command = PRIVILEGE (overrides base level)
- Pipeline `cmd1 | cmd2` = highest of both
- `curl | bash` or `wget && chmod +x` = PRIVILEGE (arbitrary execution)
### Emergency Stop
If any command reveals:
- Wrong server (hostname mismatch)
- Production when expecting staging
- Root filesystem <5% free
- Unexpected critical services
**STOP immediately.** Report findings. Wait for user confirmation.
### Docker Operations
For Docker registry auth, ask user for credentials via AskUserQuestion. Never hardcode tokens.
```bash
# GHCR login
echo "$TOKEN" | ssh ALIAS "docker login ghcr.io -u USERNAME --password-stdin"
# DockerHub login
echo "$TOKEN" | ssh ALIAS "docker login -u USERNAME --password-stdin"
```
### Output Format
```markdown
## SSH Task Report
| Field | Value |
|-------|-------|
| Server | [name] ([host]) |
| Task | [description] |
| Commands | [N] executed |
| Classification | [highest level] |
| Status | success / partial / failed |
### Commands Executed
1. `[command]` -- [result]
2. `[command]` -- [result]
### Changes Made
- [change 1]
- [change 2]
```