Hardening checklist
A self-hosted ShellWatch is most of the way there out of the box, but a few defaults are deliberately permissive (so first-run “just works” on localhost). Tighten these before exposing it.
1. Lock down security.allowedNetworks
Section titled “1. Lock down security.allowedNetworks”Default: 127.0.0.1/32 and ::1/128.
This applies to /mcp and /agent-proxy only — the Web UI is authenticated separately. Once you’ve set trustProxy, real client IPs are visible to the allowlist; either narrow the CIDRs to the offices/VPNs that should have agent access, or open it up explicitly with 0.0.0.0/0 if you’re relying purely on the OAuth token + passkey gate.
security: allowedNetworks: - 10.10.0.0/16 # corporate VPN - 198.51.100.42/32 # CI runner static IP2. Pin the relying-party ID and trusted origins
Section titled “2. Pin the relying-party ID and trusted origins”Passkeys are bound to the relying-party ID, which must match the domain you serve from. Get this right before users start registering — changing it later invalidates every existing passkey.
security: rpId: shellwatch.example.com trustedWebauthnOrigins: - https://shellwatch.example.comOrigins are checked exactly — include the protocol and port if non-standard. No wildcards.
3. Decide on self-registration
Section titled “3. Decide on self-registration”Default: selfRegistrationEnabled: false. The first registration on a fresh install always works (so you can bootstrap the admin), but subsequent registrations are blocked unless you flip this on.
security: selfRegistrationEnabled: false # admin invites onlyFor a personal install, leave it off. For a team install, leave it off and have the admin invite users (or flip on temporarily, register, flip off).
4. Configure rate limits
Section titled “4. Configure rate limits”Per-IP rate limits on auth-adjacent endpoints. Defaults are sane but tune for your population:
security: rateLimit: selfRegister: max: 5 windowMinutes: 15 passkeyRegister: max: 10 windowMinutes: 15 loginOptions: max: 20 windowMinutes: 15 loginVerify: max: 10 windowMinutes: 15The store is in-memory — limits are per-process, not per-cluster. If you’re running multiple replicas (you shouldn’t — see below), each gets its own counters.
5. Enforce User Verification on sshd
Section titled “5. Enforce User Verification on sshd”ShellWatch performs WebAuthn ceremonies with userVerification: "required", but UV is only as good as the server’s enforcement. On every target host that should require UV:
PubkeyAuthOptions verify-required…or per-key in ~/.ssh/authorized_keys:
verify-required sk-ecdsa-sha2-nistp256@openssh.com AAAA... user@hostSee SSH agent proxy → Enforcing UV.
6. Keep Hydra’s admin port off the internet
Section titled “6. Keep Hydra’s admin port off the internet”Hydra’s admin API (hydra.adminUrl, port 4445) accepts login/consent decisions, OAuth client CRUD, and token introspection with no further authentication. Only ShellWatch may reach it:
- In compose, don’t publish the port at all (or bind it to
127.0.0.1only). - Never add a reverse-proxy route for it.
- Verify from outside that
:4445is unreachable.
While you’re at it: set a real HYDRA_SECRETS_SYSTEM (openssl rand -hex 16) in .env.hydra — the dev default is public knowledge, and it encrypts Hydra’s data at rest. Rotating it later invalidates all existing tokens and sessions. Drop the --dev flag from the Hydra service command for any internet-facing deployment (it accepts an http:// issuer without TLS and relaxes other checks).
Also consider hydra.introspectionCacheTtlMs: introspection results are cached for 60 s by default, so a revoked or logged-out token can keep working for up to that long. Lower it (or set 0) for tighter revocation latency.
7. Lock down file permissions
Section titled “7. Lock down file permissions”sudo chmod 600 /opt/shellwatch/config.yamlsudo chmod 700 /opt/shellwatch/dataRun the broker as a dedicated low-privilege user (shellwatch) with NoNewPrivileges and ProtectSystem=strict if using systemd.
8. Back up data/
Section titled “8. Back up data/”The SQLite database holds everything mutable on the ShellWatch side — accounts, passkeys, endpoints, and the audit tables (audit_session_lifecycle and audit_signing_requests; session input/output is not persisted). Snapshot the directory before upgrades, and have a retention policy. SQLite in WAL mode is safe to copy with sqlite3 ... ".backup ..." from a hot process; a plain cp of the .db + .db-wal + .db-shm triplet works for a stopped process.
Hydra’s hydra.sqlite (in the same ./data directory) holds the OAuth side — clients, grants, and tokens. Losing it doesn’t lose accounts, but every client (web, MCP, agent) has to re-authenticate. Backing up the whole ./data directory covers both databases. (pnpm hydra:migrate also snapshots hydra.sqlite before applying schema changes.)
9. Single-process, single-host
Section titled “9. Single-process, single-host”ShellWatch is a single Node.js process and is not designed for horizontal scaling:
- Terminal sessions live in process memory.
- The
PendingActionStoreis in-memory. - Rate-limit counters are in-memory.
- WebSocket fan-out is per-process.
If you need HA, run an active/passive setup with shared storage and a single hot node at any time. Active/active will not work without significant changes.
For a typical personal or small-team broker, one well-backed-up VM is the right sizing.
10. Monitor /health
Section titled “10. Monitor /health”GET /health returns 200 when the server is up. Use it for your load balancer health check and for external uptime monitoring. The Docker image has a baked-in HEALTHCHECK that hits this endpoint.
11. Subscribe to the agent client too
Section titled “11. Subscribe to the agent client too”If you publish a shellwatch-agent binary to your team, watch the agent-client releases and ship updates — particularly to pull in OpenSSH-version compatibility fixes and the X-ShellWatch-Version handshake header. The version is shown to approvers on /sign/:id, so keeping it current is part of the audit story.