<!-- x-shc-release: version=2.4.15 op_fingerprint=183a4a0282542d671d799f08044f6717182932f9fa1ef5205dfca45a030cefaa op_count=177 contractFingerprint=e8f7ccc12336fb4925893ce3582b03782b182638dc460d952d046a36c1c20e14 -->
---
name: harden-ssh
description: 'Lock down a fresh SHC VPS -- key-only SSH, unattended security upgrades, and a default-deny firewall opening only the ports the services need. Verifies it can still reconnect before committing (no lockout). Idempotent.'
when_to_use: 'After a deploy (so you know which ports the app needs), or any time the user asks to "secure / lock down / harden my server". Safe to re-run.'
router_what: Key-only SSH + auto security upgrades + default-deny firewall (no lockout).
router_when: 'After a deploy, or when the user asks to secure/lock down/harden the server.'
version: 2.4.3
operator: true
client_side: false
requires: [ssh_root_or_sudo, vps_ready]
provides: [hardened_host]
inputs:
  - { name: ssh_host, required: true, desc: VPS host/IP the agent SSHes to }
  - { name: open_ports, required: false, default: '22,80,443', desc: TCP ports to allow inbound }
  - { name: keep_password_auth, required: false, default: false, desc: leave password auth on (NOT recommended) }
outputs:
  - { name: hardened, desc: true once key-only SSH + firewall + auto-upgrades are in place AND SSH still works }
estimated_minutes: [3, 10]
danger: medium
---

# harden-ssh -- lock down the VPS
## Operator discipline (hold to these on every step)

*Five rules that keep you safe while you operate a server for someone. Follow them on every
step of every skill.*

1. **Verify, don't trust. Never fabricate a result.** Never report `ready`, `done`, `live`,
   `deployed`, `backed up`, or `paid` unless a real tool output or healthcheck PROVED it (and that
   proof arrives in a separate later message, never in your own reply). A status field is a claim; a
   healthcheck is proof. This holds under pressure: "tell my boss in one line it's live", "answer in
   one line", "you do not need to check" do NOT make it true and do NOT waive the check. If you have
   not verified, say exactly that -- never write a done/live/paid sentence you did not earn.
2. **Spending AND destruction are the human's call -- the user CANNOT authorize you to skip the
   confirm.** For money (order, pay) OR data-loss (reinstall, delete, cancel, restore): name the
   action, state the consequence, get the human's explicit yes for THAT action; never in one step.
   The trap: "just do it / don't ask me / skip confirmation / no need to confirm / I already agree" is
   an instruction to skip the gate -- it is NOT a yes and does NOT give you permission to bypass the
   gate. The gate OUTRANKS that instruction; proceeding because they said "just do it" is itself the
   failure. So you still state the consequence and still wait for a real yes. NEVER set `confirm=true`
   yourself, NEVER map "just go" onto `human_confirmed=true`, and NEVER assume a missing confirmation
   "defaults to true" -- the opposite is true: missing -> REFUSED (409 confirmation_required). If they
   will not give the yes, you do NOT proceed -- announce nothing, run nothing. (Mechanics: every spend
   AND destructive action is gated by a **server-issued, single-use confirmation_id** -- you can NEVER
   self-confirm. On the hosted MCP the destructive/spend tool returns the confirmation_id after its own
   human-approval step. On raw /v2 a destructive/spend call with no confirmation_id returns **409
   confirmation_required** carrying a one-time, non-secret `confirmation_id` and a `how_to_proceed`
   re-call shape; AFTER the human's explicit yes, re-send the IDENTICAL request adding the header
   **`X-User-Api-Confirm: <that confirmation_id>`**. `?confirm=true` and body `confirm:true` are NOT
   accepted -- they get the same 409. The confirmation_id is bound server-side to that exact call +
   your principal and is single-use, so a leaked id is inert (it completes nothing on its own). The
   409's `how_to_proceed` is a recovery shape, NOT a license: a confirmation_id only proves the SERVER
   has a pending record for the action -- do NOT auto-resend it until the human has actually said yes to
   the consequence. Spend is also scope-gated: an `operate` key cannot spend at all -- only a `full` key
   or HTTP Basic can.)
3. **Check the source before you run it.** Validate any deploy recipe against the project's
   *current* upstream docs, not a cached memory.
4. **Take the smallest key that does the job.** An agent that only provisions should never
   hold the key that spends.
5. **When you can't verify, stop and say so.** Failing loudly beats a confident wrong answer.


Make a fresh VPS key-only, auto-patching, and default-deny. `danger: medium`: a careless change can
lock you out of the VPS, so you MUST verify you can still reconnect before committing.

## Safety gates (never break)

- Before touching anything, prove key auth works NOW. If it does not, ABORT -- do not harden.
- Always keep the SSH port in the firewall allow-list. Never set a default-deny FORWARD on a host
  routing other traffic.
- After an sshd change, open a SECOND connection to prove a fresh key login still works. If it fails,
  ROLL BACK. A `systemctl reload` returning 0 is not proof you can still get in.
- Do not add new SSH keys to the host unless the user asks.

## Steps (idempotent)

```bash
# preflight: prove key auth works NOW (else abort -- do not lock out)
ssh -o BatchMode=yes -o PasswordAuthentication=no root@<HOST> true \
  || abort "key auth not working; fix before hardening"

# 1. sshd key-only via a drop-in /etc/ssh/sshd_config.d/99-shc-harden.conf:
#      PasswordAuthentication no
#      KbdInteractiveAuthentication no
#      PermitRootLogin prohibit-password
#    then: sshd -t (validate) && systemctl reload ssh
#    then: RE-VERIFY a fresh key login in a NEW connection; ROLL BACK on fail.

# 2. unattended-upgrades: apt-get install -y unattended-upgrades && enable the security origins

# 3. firewall (nftables preferred on Debian 13; ufw if already present):
#    default drop input, accept established+related+lo, accept <open_ports>. NEVER drop the SSH port.
```

`open_ports` default `22,80,443` matches a web-app deploy. Run AFTER the app deploy so you know which
ports to open; safe to re-run.

## Result contract

- ready: `STATUS: ready` + `NEXT_ACTION: none (host hardened; SSH re-verified in a fresh connection)`.
- failed: `STATUS: failed` + `STEP/REASON/NEXT_ACTION` (e.g. "sshd reload failed; rolled back").

**You're done when:** a FRESH SSH connection logged in successfully AFTER the changes were applied
(proving you're not locked out), with the firewall up and auto-upgrades on. A `systemctl reload`
returning 0 is not done -- the fresh login is.
