cloverleaf-larry/lib/ssh-helper.sh
Bryan Johnson f58bcf711f v0.6.0: secure SSH ControlMaster — password hidden from Larry-the-LLM
NEW lib/ssh-helper.sh implements the full SSH command surface:
  hosts/list                              show configured remote hosts
  add <alias> <user@host[:port]>          register a new host
  remove <alias>                          remove + clean cred + socket
  pass <alias>                            set/update password (hidden interactive)
  setup <alias>                           open long-lived ControlMaster
  close <alias>                           close ControlMaster
  status [alias]                          show open masters + cred presence
  exec <alias> <command...>               run command via master

Architecture:
  • $LARRY_HOME/.ssh-hosts.tsv      — alias \t user@host \t port (3-col)
  • $LARRY_HOME/.ssh-creds/<alias>  — raw password, mode 0600
  • $LARRY_HOME/.ssh-sockets/<alias>.sock — ControlMaster socket

The password is read from disk by sshpass via -f (file argument), so it
never lands in argv or environment. It is used ONCE to open the master;
all subsequent execs multiplex through the socket with no auth. Daily-
rotating passwords: just overwrite the cred file and re-run setup.

SLASH COMMANDS wired in larry.sh REPL: /ssh-hosts /ssh-add /ssh-remove
/ssh-pass /ssh-setup /ssh-close /ssh-status /ssh <alias> <cmd>.

LARRY TOOLS exposed to the LLM:
  ssh_status      — list aliases + open-master state
  ssh_exec        — run command on remote via the master socket
Both tool descriptions explicitly tell Larry the password is unreachable
and to ask Bryan to run /ssh-setup if a master is closed. Tool inputs
and outputs never contain the password. Output capped at max_lines
(default 500) with a "[ssh_exec: exit rc=N]" footer.

Bundle updated: MANIFEST + install-larry.sh both now include
lib/ssh-helper.sh. Auto-update will pull it on next launch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 10:28:37 -07:00

254 lines
9.4 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# ssh-helper.sh — secure SSH command execution via ControlMaster.
#
# Architecture:
# • Hosts configured in $LARRY_HOME/.ssh-hosts.tsv as
# alias \t user@host \t port
# • Passwords stored at $LARRY_HOME/.ssh-creds/<alias>, mode 0600.
# The password file is the single point of truth — to rotate (daily-changing
# passwords) just overwrite the file with the new one and re-run 'setup'.
# • sshpass reads the password via -f (file), so it never lands in argv or
# environment where Larry the LLM (or other processes via /proc) could see it.
# • The first 'setup' call opens a long-lived SSH ControlMaster connection
# (default ControlPersist=8h). Subsequent 'exec' calls multiplex through
# the master socket and need no password.
# • Larry's tool layer only sees: alias, command, command_output.
# Never the password. Never the user@host (unless added to the alias list).
#
# Subcommands:
# hosts list configured hosts
# add <alias> <user@host[:port]> add a host to the alias list
# remove <alias> remove an alias (also clears cred + socket)
# pass <alias> set/update the password (hidden interactive)
# setup <alias> open ControlMaster (uses stored password ONCE)
# close <alias> close ControlMaster
# status [alias] show open masters / cred presence
# exec <alias> <command...> run command via master (returns output)
# help print this help
set -u
set -o pipefail
LARRY_HOME="${LARRY_HOME:-$HOME/.larry}"
SSH_HOSTS_FILE="$LARRY_HOME/.ssh-hosts.tsv"
SSH_CREDS_DIR="$LARRY_HOME/.ssh-creds"
SSH_SOCKETS_DIR="$LARRY_HOME/.ssh-sockets"
SSH_CONTROL_PERSIST="${LARRY_SSH_CONTROL_PERSIST:-8h}"
die() { printf 'ssh-helper: %s\n' "$*" >&2; exit 1; }
warn() { printf 'ssh-helper: warn: %s\n' "$*" >&2; }
ok() { printf 'ssh-helper: %s\n' "$*"; }
ensure_layout() {
mkdir -p "$LARRY_HOME" "$SSH_CREDS_DIR" "$SSH_SOCKETS_DIR" 2>/dev/null
chmod 700 "$LARRY_HOME" "$SSH_CREDS_DIR" "$SSH_SOCKETS_DIR" 2>/dev/null || true
if [ ! -f "$SSH_HOSTS_FILE" ]; then
umask 077
printf 'alias\taddr\tport\n' > "$SSH_HOSTS_FILE"
chmod 600 "$SSH_HOSTS_FILE"
fi
}
# read_host_addr ALIAS → echoes "ADDR\tPORT" or empty
read_host_addr() {
local alias="$1"
[ -f "$SSH_HOSTS_FILE" ] || { printf ''; return 1; }
awk -F'\t' -v a="$alias" 'NR>1 && $1==a { print $2 "\t" $3; exit }' < "$SSH_HOSTS_FILE"
}
require_sshpass() {
command -v sshpass >/dev/null 2>&1 \
|| die "sshpass not on PATH — install it (apt install sshpass / brew install sshpass) and retry"
}
cmd_help() {
sed -n '4,30p' "$0"
}
cmd_hosts() {
ensure_layout
if [ "$(wc -l < "$SSH_HOSTS_FILE")" -le 1 ]; then
echo "no hosts configured. Add with: ssh-helper.sh add <alias> <user@host[:port]>"
return 0
fi
printf 'alias user@host port cred master\n'
printf '%s\n' '───── ───────── ──── ──── ──────'
awk -F'\t' 'NR>1' "$SSH_HOSTS_FILE" | while IFS=$'\t' read -r alias addr port; do
local cred_state=""
[ -f "$SSH_CREDS_DIR/$alias" ] && cred_state="✓"
local master_state=""
local sock="$SSH_SOCKETS_DIR/$alias.sock"
if [ -S "$sock" ] && ssh -S "$sock" -O check -p "$port" "$addr" 2>/dev/null; then
master_state="open"
fi
printf '%-20s%-52s%-6s%-6s%s\n' "$alias" "$addr" "${port:-22}" "$cred_state" "$master_state"
done
}
cmd_add() {
local alias="${1:-}" target="${2:-}"
[ -n "$alias" ] && [ -n "$target" ] || die "usage: add <alias> <user@host[:port]>"
[[ "$target" =~ ^[^@[:space:]]+@[^:[:space:]]+(:[0-9]+)?$ ]] \
|| die "target must look like user@host or user@host:port"
local addr port
if [[ "$target" == *:* ]]; then
addr="${target%:*}"
port="${target##*:}"
else
addr="$target"
port="22"
fi
ensure_layout
# Reject duplicates (use 'remove' first)
if awk -F'\t' -v a="$alias" 'NR>1 && $1==a { found=1; exit } END { exit !found }' "$SSH_HOSTS_FILE"; then
die "alias '$alias' already exists. Use 'remove $alias' first."
fi
umask 077
printf '%s\t%s\t%s\n' "$alias" "$addr" "$port" >> "$SSH_HOSTS_FILE"
chmod 600 "$SSH_HOSTS_FILE"
ok "added $alias$addr (port $port). Next: ssh-helper.sh pass $alias"
}
cmd_remove() {
local alias="${1:-}"
[ -n "$alias" ] || die "usage: remove <alias>"
ensure_layout
local tmp; tmp=$(mktemp)
awk -F'\t' -v a="$alias" 'NR==1 || $1!=a' "$SSH_HOSTS_FILE" > "$tmp" && mv "$tmp" "$SSH_HOSTS_FILE"
chmod 600 "$SSH_HOSTS_FILE"
# Close + clean master socket and cred
cmd_close "$alias" 2>/dev/null || true
rm -f "$SSH_CREDS_DIR/$alias" "$SSH_SOCKETS_DIR/$alias.sock" 2>/dev/null
ok "removed $alias (cred + socket cleared)"
}
cmd_pass() {
local alias="${1:-}"
[ -n "$alias" ] || die "usage: pass <alias>"
local addr_port; addr_port=$(read_host_addr "$alias")
[ -n "$addr_port" ] || die "no such alias: $alias (run 'add' first)"
ensure_layout
printf 'Password for %s (input is hidden; press Enter when done): ' "$alias" >&2
local pw=""
stty -echo 2>/dev/null
IFS= read -r pw </dev/tty || true
stty echo 2>/dev/null
echo "" >&2
[ -n "$pw" ] || die "no password entered"
umask 077
# NO trailing newline — sshpass -f expects raw password as full file content
printf '%s' "$pw" > "$SSH_CREDS_DIR/$alias"
chmod 600 "$SSH_CREDS_DIR/$alias"
ok "password saved to $SSH_CREDS_DIR/$alias (mode 0600). Next: ssh-helper.sh setup $alias"
}
cmd_setup() {
local alias="${1:-}"
[ -n "$alias" ] || die "usage: setup <alias>"
local addr_port; addr_port=$(read_host_addr "$alias")
[ -n "$addr_port" ] || die "no such alias: $alias"
local addr port
addr=$(printf '%s' "$addr_port" | cut -f1)
port=$(printf '%s' "$addr_port" | cut -f2)
ensure_layout
local sock="$SSH_SOCKETS_DIR/$alias.sock"
if [ -S "$sock" ] && ssh -S "$sock" -O check -p "$port" "$addr" 2>/dev/null; then
ok "master already open for $alias ($addr:$port)"
return 0
fi
local credfile="$SSH_CREDS_DIR/$alias"
[ -f "$credfile" ] || die "no password set for $alias — run 'pass $alias' first"
require_sshpass
ok "opening ssh master for $alias ($addr:$port) — ControlPersist=$SSH_CONTROL_PERSIST..."
if sshpass -f "$credfile" ssh \
-o "ControlMaster=yes" \
-o "ControlPath=$sock" \
-o "ControlPersist=$SSH_CONTROL_PERSIST" \
-o "StrictHostKeyChecking=accept-new" \
-o "ConnectTimeout=10" \
-p "$port" \
-N -f \
"$addr" 2>/tmp/larry-ssh-setup.err; then
if ssh -S "$sock" -O check -p "$port" "$addr" 2>/dev/null; then
ok "✓ master open: $alias$addr:$port (socket: $sock)"
rm -f /tmp/larry-ssh-setup.err
return 0
fi
fi
printf 'ssh-helper: setup failed. sshpass/ssh stderr:\n' >&2
cat /tmp/larry-ssh-setup.err >&2 2>/dev/null
rm -f /tmp/larry-ssh-setup.err
return 1
}
cmd_close() {
local alias="${1:-}"
[ -n "$alias" ] || die "usage: close <alias>"
local addr_port; addr_port=$(read_host_addr "$alias") || addr_port=""
local sock="$SSH_SOCKETS_DIR/$alias.sock"
if [ -S "$sock" ] && [ -n "$addr_port" ]; then
local addr port
addr=$(printf '%s' "$addr_port" | cut -f1)
port=$(printf '%s' "$addr_port" | cut -f2)
ssh -S "$sock" -O exit -p "$port" "$addr" 2>/dev/null || true
fi
rm -f "$sock"
ok "closed master for $alias"
}
cmd_status() {
ensure_layout
if [ -n "${1:-}" ]; then
local alias="$1"
local addr_port; addr_port=$(read_host_addr "$alias")
[ -n "$addr_port" ] || die "no such alias: $alias"
local addr port
addr=$(printf '%s' "$addr_port" | cut -f1)
port=$(printf '%s' "$addr_port" | cut -f2)
local sock="$SSH_SOCKETS_DIR/$alias.sock"
printf 'alias: %s\naddr: %s\nport: %s\ncred: %s\nsocket: %s\nstatus: ' \
"$alias" "$addr" "$port" \
"$([ -f "$SSH_CREDS_DIR/$alias" ] && echo present || echo missing)" \
"$sock"
if [ -S "$sock" ] && ssh -S "$sock" -O check -p "$port" "$addr" 2>/dev/null; then
echo "master OPEN"
else
echo "no master (run setup)"
fi
return 0
fi
cmd_hosts
}
cmd_exec() {
local alias="${1:-}"
[ -n "$alias" ] || die "usage: exec <alias> <command...>"
shift
local cmd="$*"
[ -n "$cmd" ] || die "no command given"
local addr_port; addr_port=$(read_host_addr "$alias")
[ -n "$addr_port" ] || die "no such alias: $alias"
local addr port
addr=$(printf '%s' "$addr_port" | cut -f1)
port=$(printf '%s' "$addr_port" | cut -f2)
local sock="$SSH_SOCKETS_DIR/$alias.sock"
if [ ! -S "$sock" ] || ! ssh -S "$sock" -O check -p "$port" "$addr" 2>/dev/null; then
die "no open master for $alias — run 'setup $alias' first"
fi
# Multiplexed; no password needed.
ssh -S "$sock" -p "$port" -o BatchMode=yes "$addr" "$cmd"
}
case "${1:-help}" in
hosts|list) shift; cmd_hosts ;;
add) shift; cmd_add "$@" ;;
remove|rm) shift; cmd_remove "$@" ;;
pass|passwd) shift; cmd_pass "$@" ;;
setup|open) shift; cmd_setup "$@" ;;
close|exit) shift; cmd_close "$@" ;;
status) shift; cmd_status "$@" ;;
exec|run) shift; cmd_exec "$@" ;;
-h|--help|help) cmd_help ;;
*) die "unknown subcommand: ${1:-} (run with --help)" ;;
esac