Sshepherd
Skill Antheurus/sshepherd
Use this skill whenever the user mentions "sshepherd" by name, or wants to check on a remote server (health, disk, memory, CPU, ports, OOM history), inspect or restart docker/systemd services, tail remote logs, read or write remote config files, deploy a project via a named recipe, introspect a remote Postgres database, or audit SSH/security posture on a box — while keeping the agent zero-knowledge about credentials (no password, private key, or host/user/port ever reaches the agent's context or a tool response). Covers all 9 registry-driven command groups (hosts, check, logs, services, deploy, config, db, files, security) via a single compiled Bun/TypeScript CLI that shells out to the system `ssh` binary — the agent only ever passes an ssh alias, a pg-target name, or a recipe name, never a credential. `setup` is a separate group (deliberately not one of the 9) that writes sshepherd's own local config files; every `setup` action is agent-invocable, with one narrow exception — `setup ssh-alias install` opens a one-shot browser form that only a human can type a password into.From its SKILL.md
npx -y skills add Antheurus/sshepherdAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- 27 days oldThe repository was created 27 days ago. New is not bad, but a brand new repository carrying a familiar-sounding name is the shape a typosquat arrives in, and there has been no time for anyone else to find a problem with it.
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
21.2 KB, ~5.4k tokens by cl100k_base, as published. Nobody here has run it
sshepherd
sshepherd is a compiled Bun/TypeScript CLI for server operations over SSH — health
checks, service/container control, log tailing, config edits, Postgres introspection, and
declarative deploys — built so an agent can drive a real server without ever seeing a
password, private key, host, user, or port. Every op resolves through an ssh alias already
configured in ~/.ssh/config (or a db-group pg-target / deploy-group recipe name that
itself resolves to an alias); OpenSSH does the actual authentication, entirely outside this
process.
Binary: /Users/macbook/Documents/PROJECT_MISPAQUL_ATTORIQ/sshepherd/dist/sshepherd
If the binary is missing, build it first:
cd /Users/macbook/Documents/PROJECT_MISPAQUL_ATTORIQ/sshepherd && just build
Call the binary by its absolute path — it is not on PATH. Every example below uses the
bare sshepherd name for brevity; substitute the absolute path when invoking.
Deep references (read on demand, not upfront):
references/transport.md— the zero-knowledge SSH model, ControlMaster lifecycle, the error classification enum, why stderr is discarded entirely.references/recipes.md— the TOML deploy-recipe format, every step kind,depends_onordering, the[rollback]block,--dry-runplan shape, a complete worked recipe.references/db.md— the pg-target model, read-only enforcement layers, thedbops' output shapes, whydb queryrejects multi-statement SQL.references/output-shapes.md— theEnvelopeshape, log-line objects, verdict fields, theErrorInfoshape — read this before parsing any command'sdata.
The zero-knowledge model
The agent never types, sees, or passes a hostname, IP, username, port, password, or private key through sshepherd — not as an argument, not in a response — with one narrow, scoped exception noted below.
- The agent passes only a name: an ssh alias (
web-01), a pg-target name (prod), or a recipe name (demo). Every alias/target/recipe is declared once, ahead of time, in~/.ssh/config,~/.config/sshepherd/targets.toml, or a recipe TOML — never on the command line. - OpenSSH resolves the real connection tuple (
HostName/User/Port/IdentityFile) internally; sshepherd shells out to the systemsshbinary (never thessh2npm library) so credential handling stays entirely inside OpenSSH's own trusted code path. - Every response
Envelopeechoes back only thealiasit was given — there is no host/user/port/ip field anywhere in the response shape, structurally. - The one scoped exception:
setup ssh-alias status <alias>echoes that alias's ownhost/user/port(plushasKey) back indata— not new exposure, since the caller already supplied those same values toregisterin the first place.setup ssh-alias liststays name-only, and every other command in the tool, including all 10 registry groups, still returns only the alias/target/recipe name. - ssh's own stderr is discarded entirely (never surfaced, never logged) — it's classified into a small error enum instead, because OpenSSH's stderr phrasing varies by version/locale and can leak a hostname no redaction allowlist would catch.
hosts listandsetup ssh-alias listreturn alias names only, neverHostName/User/Port.- Every
filesop (ls/cat/tail/download/disk-usage/upload) refuses any remote path not pre-declared for that alias in~/.config/sshepherd/files-allowlist.toml— fail-closed, same rule asconfig get/put/validateandconfig-allowlist.toml. See Gotchas #11. .env-shaped files (files cat) are masked by default (KEY=***MASKED***); an agent must pass--reveal KEY1,KEY2to unmask specific keys, and each key must clear a hardcoded secret-pattern denylist (PASSWORD,SECRET,TOKEN,PRIVATE_KEY,CREDENTIAL,API_KEY, ...) and be pre-declared in~/.config/sshepherd/reveal-allowlist.toml— the denylist wins even over a mistaken allowlist entry. See Gotchas #11.files downloadwrites the remote file straight to a local destination path and never returns its content in the JSON envelope — the safe way to pull any secrets-bearing file to disk (see Gotchas #10 for the incident this closed).- Every mutating op writes an audit line (
~/.local/state/sshepherd/audit.jsonl) — timestamp, alias, command, an arg hash (not raw args), and outcome — success or failure. tunnel open's--remote/--localflags describe the forward target/exposed service as seen from the alias's own network (almost alwayslocalhost:<port>) — they are NOT the alias's own connection identity, so accepting them as free text doesn't weaken the zero-knowledge boundary; the response still never carriesHostName/User/Port.
Command shape
sshepherd <group> <action> [positionals...] [--flag value]
sshepherd --help # lists 10 registry groups + the setup group
sshepherd <group> --help # lists that group's actions + args/flags
sshepherd <group> <action> --help # shows one action's args
The first positional differs by group:
| Group | First positional | Resolves via |
|---|---|---|
db (except list) | <target> — a pg-target name from targets.toml | targets.ts |
deploy (all actions) | <recipe> — a recipe name | recipes.ts |
hosts list, db list | (none — host-local, no ssh) | — |
tunnel list, tunnel close | (none — list is host-local; close takes <id> instead) | — |
| every other group | <alias> — an ssh alias from ~/.ssh/config | — |
Global flags
| Flag | Effect |
|---|---|
--yes | confirm a mutating op — required; sshepherd never prompts interactively |
--dry-run | deploy run only: print the resolved plan, execute nothing, no --yes needed |
--pretty | render a human table/key-value view instead of JSON |
--reveal <keys> | files cat only: comma-separated env-var keys to unmask |
--from <path> | config put only: local file to read + base64-encode (instead of typing --content-base64 by hand) |
Output is JSON to stdout by default (one Envelope per call — see references/output-shapes.md).
Exit codes: 0 success, 1 the op ran and failed (transport/command error, or a refused
CONFIRMATION_REQUIRED), 2 a usage error (unknown group/action, missing required
argument — no ssh connection was attempted).
Quick reference — 10 registry-driven groups (55 ops) + 1 setup group (6 sub-groups, 12 actions)
# hosts
sshepherd hosts list
sshepherd hosts test web-01
sshepherd hosts info web-01
# check
sshepherd check overview web-01
sshepherd check mem web-01
sshepherd check disk web-01
sshepherd check cpu web-01
sshepherd check ports web-01
sshepherd check oom-history web-01
sshepherd check kernel web-01
# logs
sshepherd logs docker web-01 myapp --tail 100
sshepherd logs service web-01 nginx --tail 100
sshepherd logs docker-daemon web-01 --tail 100
sshepherd logs nginx web-01 error --tail 100
# services
sshepherd services ps web-01
sshepherd services stats web-01
sshepherd services inspect web-01 myapp
sshepherd services compose-ps web-01 /opt/myapp/docker-compose.yml
sshepherd services healthcheck web-01 myapp
sshepherd services systemctl-status web-01 nginx
sshepherd services restart web-01 myapp --yes
sshepherd services systemctl-start web-01 nginx --yes
sshepherd services systemctl-stop web-01 nginx --yes
sshepherd services systemctl-restart web-01 nginx --yes
sshepherd services systemctl-reload web-01 nginx --yes
# files
sshepherd files ls web-01 /opt/myapp
sshepherd files cat web-01 /opt/myapp/.env --reveal DB_HOST
sshepherd files tail web-01 /var/log/syslog --n 100
sshepherd files download web-01 /opt/myapp/backup.sql ./backup.sql
sshepherd files disk-usage web-01 /var/lib/docker
sshepherd files upload web-01 ./local.conf /opt/myapp/local.conf --yes
# config
sshepherd config get web-01 /etc/nginx/nginx.conf
sshepherd config validate web-01 /etc/nginx/nginx.conf
sshepherd config put web-01 /etc/nginx/nginx.conf --from ./nginx.conf --yes
sshepherd config reload web-01 nginx --yes
# db
sshepherd db list
sshepherd db tables prod
sshepherd db activity prod
sshepherd db connections prod
sshepherd db slow prod
sshepherd db size prod
sshepherd db query prod "SELECT count(*) FROM users"
# deploy
sshepherd deploy run demo --dry-run
sshepherd deploy run demo --yes
sshepherd deploy status demo
sshepherd deploy rollback demo --yes
sshepherd deploy logs demo --tail 100
sshepherd deploy migrate demo --yes
# security
sshepherd security harden web-01 --yes
sshepherd security ssh-audit web-01
sshepherd security listeners web-01
sshepherd security authorized-keys web-01
sshepherd security fail2ban web-01
# tunnel
sshepherd tunnel open web-01 --kind local --remote localhost:5432 --duration 1800 --yes
sshepherd tunnel list
sshepherd tunnel close t-a1b2c3d4 --yes
# setup — agent-invocable; install's credential boundary is the one exception (see Gotchas #9)
sshepherd setup ssh-alias register myserver --host 1.2.3.4 --user deploy --yes
sshepherd setup ssh-alias keygen myserver --yes
sshepherd setup ssh-alias install myserver --yes
sshepherd setup ssh-alias list
sshepherd setup ssh-alias status myserver
sshepherd setup ssh-alias update myserver --host 5.6.7.8 --port 2222 --yes
sshepherd setup ssh-alias remove myserver --yes
sshepherd setup db-target scaffold prod --alias myserver --user app --database appdb --container app_db --yes
sshepherd setup config-allowlist scaffold myserver --paths /etc/nginx/nginx.conf,/opt/app/.env --yes
sshepherd setup deploy-recipe scaffold demo --alias myserver --workdir /opt/app --yes
sshepherd setup files-allowlist scaffold myserver --paths /opt/app/backup.sql,/opt/app/.env --yes
sshepherd setup reveal-allowlist scaffold myserver --keys NODE_ENV,APP_REGION --yes
Gotchas
- Zero-knowledge is not optional per-call. There is no flag to pass a raw host/user/ port/password — the only way to reach a server is to declare it as an ssh alias (or a pg-target/recipe pointing at one) ahead of time, outside this tool.
- Every mutating op needs
--yes, always writes an audit line. sshepherd never prompts interactively (agent-first design) — without--yesa mutating op returns aCONFIRMATION_REQUIREDenvelope and refuses before touching ssh. Success and failure both get an audit line in~/.local/state/sshepherd/audit.jsonl. - No raw exec, ever. There is no
sshepherd exec "<any command>". A genuinely novel need is authored as a named, versioned recipe step (references/recipes.md) — reviewable, not a free-text shell escape hatch.plain ssh <alias>remains the intentional human break-glass for one-off exploration. deploy rollbackrefuses without a[rollback]block. A recipe that doesn't declare one has no inferred rollback — sshepherd never guesses.config putbacks up before writing, always. The existing file is copied to<path>.bak-<UTC-timestamp>in the same remote round trip, before the overwrite.config putalso refuses any path not declared on that alias's allowlist (~/.config/sshepherd/config-allowlist.toml) — a local refusal, before any ssh call.dbis Postgres-only, read-only, v1.db querytakes a singleSELECT— a bare;is rejected before the SQL is even parsed (multi-statement guard), on top of a parser check and aBEGIN TRANSACTION READ ONLYwrapper. The real boundary is the read-only DB role declared on the target (references/db.md) — treat the client-side checks as UX guardrails, not the security boundary.- A deploy failure names the step that failed.
deploy run/deploy migratesurfacedata.failed_step({index, kind, name}) on aCOMMAND_FAILEDerror, recovered from a marker each step echoes on non-zero exit — never guess which step broke from the raw output alone. security hardenwon't lock out the current session unless told to. Directives that could disable the session's own auth method (PermitRootLogin,PasswordAuthentication) are only applied when--keep-session=falseis passed explicitly; the safe subset always applies.setup's only wall isinstall's credential entry — and even that has a smart bypass first.register,keygen,remove,list,status,update,install, and the five scaffolders (db-target,config-allowlist,deploy-recipe,files-allowlist,reveal-allowlist) are all agent-invocable, gated by--yesthe same way as every other mutating op — none of them needs a human at the keyboard, except the one narrow case below. Beforeinstallever opens a browser form, it runs two cheap, non-interactive pre-checks in order: a raw-socket Tailscale-SSH banner peek (a Tailscale-fronted target refuses key install outright —TAILSCALE_SSH_DETECTED, since Tailscale SSH doesn't useauthorized_keys), then an already-trusted probe with zero new credentials (if the key is already authorized,installshort-circuits withdata.method: 'already_trusted'and no form ever opens). Only when both pre-checks come back negative doesinstallopen a one-shot local browser form, and a human, not the agent, supplies the credential there — either a password, or a pasted existing private key (rejected withINVALID_PRIVATE_KEYif it doesn't parse, orPASSPHRASE_PROTECTED_KEY_UNSUPPORTEDif it's passphrase-protected). The agent may triggerinstalland wait on it, but it structurally cannot see, log, or relay either credential — both go straight from the browser submission into the install flow and never cross back into the agent's context; the agent only ever receives the resultingSetupResult(success or a typed error code), never the password or key itself.files downloadused to inline the whole file as base64 in the JSON envelope — fixed, but treat anydist/sshepherdbuilt before this fix as unsafe on secrets. A real incident: an agent ranfiles download <alias> <remote .env.docker path> /tmp/dest.tmpexpecting scp-like behavior (write to/tmp/dest.tmp, never see the bytes). The old implementation took only one positional (<path>, remote-only) — the local destination the agent typed was silently discarded (mapArgsToCtxignores extra positionals with no error), and the entire file was base64-encoded intodata.content_base64in the envelope printed to stdout, i.e. straight into the agent's tool-result context, in a trivially-reversible encoding (base64 -d, no special access needed). Unlikefiles cat, the oldfiles downloadapplied no.envmasking at all — it inlined any file's raw bytes regardless of shape, up to the 10 MiBDOWNLOAD_MAX_BYTESguard (above that it refused withtruncated: true, not a partial leak). This defeated the zero-knowledge promise for exactly the op whose name most strongly implies "goes to disk, not to you." Fixed insrc/registry.ts(filesDownload): the op now takes two required positionals —<path>(remote source)<local_path>(local destination) — andshape()decodes the base64 andwriteFileSyncs it straight tolocal_pathinside the CLI process; the envelope'sdatais now{found, truncated, size_bytes, written, local_path}with nocontent_base64field, ever. The raw bytes still transit the ssh channel and briefly sit in local process memory to decode — that's the same local-process exposurefiles catalready has before masking runs, not a new one; what changed is that the content never crosses back into the envelope the agent reads. If a script or an older compileddist/sshepherdbinary predates this fix, do not pointfiles downloadat any.env-shaped, key, or credential file until confirmed rebuilt — check the envelope'sdatakeys:content_base64present means the vulnerable version is running.filesand--revealare fail-closed as of v0.2.2 — a fresh install can'tfiles ls/cat/download/upload/etc. anywhere until an allowlist exists. Before v0.2.2,files download/uploadhad no allowlist at all (any remote path, in or out) and--revealcould unmask any key name the agent typed, including an actually-secret one (DB_PASSWORD,AWS_SECRET_ACCESS_KEY) — flagged by an external code review and closed the same wayconfig's allowlist already worked. Now everyfilesop checks the path against~/.config/sshepherd/files-allowlist.toml(missing file = every path refused, same fail-closed rule asconfig-allowlist.toml), andfiles cat --revealadditionally checks each key against a hardcoded, non-overridable secret-pattern denylist (PASSWORD,PASSWD,SECRET,TOKEN,PRIVATE_KEY,CREDENTIAL,API_KEY, trailing_KEY/_PASS) before checking~/.config/sshepherd/reveal-allowlist.toml— the denylist wins even if a key was mistakenly added to the allowlist. Runsetup files-allowlist scaffoldand, if--revealis needed,setup reveal-allowlist scaffoldbefore using thefilesgroup on a fresh alias. The gate lives in exactly one place —enforceAllowlist()inregistry.ts, called fromexecuteOp()before any op'sbuildRemoteruns — not hand-copied per op.tunnel openself-expires via a re-invoked hidden supervisor process, not an externaltimeoutbinary — andtunnel listis NOT side-effect-free. GNUtimeoutisn't reliably present on macOS, so--durationis enforced bysshepherdre-invoking itself in a hiddentunnel __supervisemode that holds the expiry timer in-process and force-kills the realssh -N -L/-R/-Dprocess (and its own process group) when the timer fires — no new external dependency, portable across every release target. Callingtunnel listcan itself terminate a tunnel: while scanning for active tunnels, it force-kills (and removes the state record for) any tunnel that's past its expiry but whose supervisor's own timer hasn't fired yet, rather than reporting a stale entry as still active — treattunnel listas a mutating, potentially process-killing call, not a pure read, even though it needs no--yes(it's stillmutating: falseat theOpSpeclevel — the confirm gate the mutating flag controls is a separate axis from "is idempotent"). Known limitation, deliberately not hardened against: a tunnel's local process-tracking state is keyed on PID alone. If a tunnel's supervisor process exits and, in a since-widened window, the OS reuses that same PID for an unrelated process-group leader,tunnel close/tunnel list's cleanup could in principle signal the wrong process group. This requires reused-PID-happens-to-be-a-group-leader, which is a low-probability edge case for a single-operator local dev tool — tracked as a known limitation of the PID-only state schema rather than solved with start-time/cmdline verification, which was judged out of scope for this build. Also:tunnel openreturningok: trueconfirms only that the supervisor process was spawned — NOT thatsshactually bound the port or that the target is reachable; a failure at that stage surfaces later (the nexttunnel listprunes the now-dead entry), not in theopencall's own response.
Errors
| Code | Meaning |
|---|---|
UNKNOWN_ALIAS | the ssh alias isn't defined in ~/.ssh/config |
CONNECT_TIMEOUT | couldn't reach the host within the connect timeout |
AUTH_FAILED | SSH authentication failed for this alias |
HOST_KEY_MISMATCH | the remote host key doesn't match the known-hosts entry |
SSH_TRANSPORT_ERROR | ssh failed before the remote command could run, unclassified |
COMMAND_FAILED | the remote command exited non-zero (error.remote_exit carries the code) |
COMMAND_TIMEOUT | the remote command exceeded its timeout |
CONFIRMATION_REQUIRED | a mutating op ran without --yes — refused before any ssh call |
Exit codes: 0 on ok: true; 1 when the envelope's ok is false (any code above);
2 on a usage error (unknown group/action, missing required argument) — no ssh connection
is ever attempted for an exit-2.
What ships with it: 96 files
954.0 KB alongside SKILL.md, 67 of them executable
.claude-plugin/
- marketplace.json1.0 KB
- plugin.json661 B
docs/
references/
- db.md6.7 KB
- output-shapes.md5.3 KB
- recipes.md10.5 KB
- transport.md6.4 KB
scripts/
- smoke.shruns10.4 KB
- sshd-fixture/docker-compose.yml1.6 KB
- sshd-fixture/Dockerfile1.7 KB
- sshd-fixture/postgres-init/01-fixture.sql493 B
src/
- audit.tsruns1.6 KB
- cli.tsruns11.0 KB
- db.tsruns6.1 KB
- output.tsruns3.8 KB
- parsers/authorized-keys.tsruns1.5 KB
- parsers/bytes.tsruns1.9 KB
- parsers/df.tsruns2.2 KB
- parsers/dmesg-oom.tsruns1.2 KB
- parsers/docker-log.tsruns1.1 KB
- parsers/docker-ports.tsruns2.9 KB
- parsers/du.tsruns777 B
- parsers/fail2ban.tsruns2.4 KB
- parsers/free.tsruns1.5 KB
- parsers/journal.tsruns1.7 KB
- biome.jsonc488 B
- bun.lock3.9 KB
- CONTRIBUTING.md4.4 KB
- .gitignore521 B
- justfile229 B
- LICENSE1.0 KB
- package.json761 B
- README.md12.5 KB
- SECURITY.md5.7 KB
56 more files not listed here. See all 96 in the repository.