Security is non-negotiable. Tokens, passwords, and API keys must never leak to the internet. This document describes the security controls implemented at every layer of the Personal Digital Estate, the threat model they address, and the procedures that keep them current.
Layer What’s protected SSOT for secrets 1. VPS Host (Hostinger KVM2)Physical/hypervisor, network perimeter N/A (provider) 2. VPS OS (Ubuntu 24.04 LTS)SSH, filesystem, processes, firewall N/A (OS-level) 3. Hermes Setup (install + config)Service isolation, file permissions, .env Bitwarden (manual copy) 4. Hermes Gateway (runtime)API calls, LLM routing, agent behavior ~/.hermes/.env (runtime)5. Token Usage (PATs, API keys)Scoped credentials, least-privilege, rotation Bitwarden (renewal point) 6. Network (Cloudflare Tunnel)Transport, exposure, authentication Cloudflare Access 7. Backup & Recovery State durability, secret exclusion Bitwarden + CF Access email 8. Docs & Repos Information leakage, supply chain GitHub (private repos)
Control Status Detail Provider trust Accepted risk Hostinger = owner of hypervisor. Same trust model as any VPS/cloud. No Hostinger-specific APIs or tools used. KVM virtualization ✅ Full kernel-level isolation (not container-based). Your own kernel, your own userspace. Provider agnosticism ✅ Hermes runs on any Linux with Python 3.11. No Hostinger lock-in. Migration possible in ~1 hour (ADR-0008).
Hostinger employee with hypervisor access can read disk contents. Mitigation: .env file is plaintext but VPS is single-user personal estate with no client data. If this threat model changes → upgrade to encrypted-at-rest (ADR-0007 upgrade path).
VPS disk image with secrets → mitigated by excluding ~/.hermes/.env from backups and never committing it to git.
Control Status Detail Key-only authentication ✅ PasswordAuthentication no in /etc/ssh/sshd_configRoot login disabled ✅ PermitRootLogin noed25519 key type ✅ Generated: ssh-keygen -t ed25519 -C "francois@ducatillon.net-hostinger-vps" Dedicated hermes user ✅ useradd -m -s /bin/bash hermes. Not root, but has passwordless sudo.Private key storage ✅ Backed up to Bitwarden (Credential Vault). Only on M1Max + Hostinger VPS. SSH config ✅ Uses ~/.ssh/config with Host hostinger-vps alias. Key file: ~/.ssh/hostinger_vps_ed25519
Control Status Detail UFW enabled ✅ ufw allow 22/tcp && ufw --force enableInbound: port 22 only ✅ All other ports blocked. Cloudflare Tunnel is outbound-only. No open web ports ✅ Dashboard (9119), 9router (3000), and Slack webhook all behind Cloudflare Tunnel.
Control Status Detail unattended-upgrades✅ Installed and configured for security updates. Kernel patches ✅ Applied via apt upgrade (requires reboot).
Control Status Detail fail2ban ❌ Missing Not installed. Single-user VPS with key-only SSH reduces brute-force risk, but recommended for defense-in-depth.
🛠️ Action item : Install fail2ban as part of the hardening procedure. Until then, risk is accepted because SSH requires key (no password = offline brute-force only).
Control Status Detail Hermes runs as hermes user ✅ hermes-gateway runs as user service (systemctl --user).Restart=on-failure✅ Service auto-restarts after crash. No auto-start for unrelated processes. Service file permissions ✅ ~/.config/systemd/user/hermes-gateway.service owned by hermes:hermes.
Path Permissions Owner Purpose ~/.hermes/.env600hermes:hermesAPI keys, tokens — readable only by hermes user ~/.hermes/config.yaml600hermes:hermesHermes configuration (model, gateway, providers) ~/.hermes/SOUL.md644hermes:hermesAgent identity context (non-secret) ~/.hermes/CONTEXT.md644hermes:hermesDomain glossary (non-secret) ~/.hermes/sessions.db600hermes:hermesConversation history (SQLite) ~/.hermes/memory.db600hermes:hermesCross-session memory (SQLite) ~/.hermes/skills/755 / 644hermes:hermesLearned procedures (non-secret) ~/.hermes/.ssh/ (if any)700 / 600hermes:hermesSSH keys for external access
Bitwarden (Credential Vault) = SSOT for all secrets
~/.hermes/.env on Agent Host
.env is never committed to any git repo. Only .env.example exists in repos.
~/.hermes/.env is excluded from all automated backups (daily GitHub backup, weekly External HD).
Secrets are re-provisioned from Bitwarden during disaster recovery — they are NOT in the backup.
Dashboard does NOT display/edit .env values (day-one constraint).
hermes --version # Verify installation
systemctl --user status hermes-gateway # Verify service is running
ls -la ~/.hermes/.env # Verify permissions (should be 600)
Secret Used for Scope SLACK_BOT_TOKENSlack gateway authentication Send messages, read channels OPENROUTER_API_KEYLLM API calls Inference on 200+ models HERMES_GITHUB_CODE_PATGitHub code operations Selected fducat18 repos only (contents:write, pull_requests:write) HERMES_BACKUP_GITHUB_PATPush encrypted backups hermes-backups repo only (contents:write)
Concern Mitigation API data sent to OpenRouter OpenRouter does NOT use API data for training. No data retention. Accepted risk. Sensitive content (tax, medical, legal, financial) Hermes warns before sending to external LLM. Route to local model if available (roadmap). Free vs. paid models Both go through OpenRouter. Same data handling policies. Budget cap €15/month (adjustable by François). Set on OpenRouter dashboard.
Guardrail Implementation Careful assistant (default) All destructive/irreversible actions require explicit approval from François. Branch-review (default delivery) Hermes creates a branch, pushes, creates PR. François reviews before merging. No autonomous main push Hermes CANNOT push to main, delete branches, force-push, or create/delete repos. Secret redaction in logs Hermes redacts secrets in logs by default.
Least scope — each token gets ONLY the permissions it needs
Short-lived — 90-day expiry when tolerable
Per-purpose — no wildcard repo scope, no single token for everything
Stored in Bitwarden — never in plaintext in git, docs, or logs
Rotated on compromise — immediate revocation via provider dashboard, re-generate in Bitwarden
Token Storage Scope Permissions Expiry Revocation Slack bot token ~/.hermes/.env ← BitwardenSlack workspace bot, chat:write, channels:read, channels:history (minimum needed) Slack-dependent Slack app dashboard OpenRouter API key ~/.hermes/.env ← BitwardenOpenRouter Inference only No expiry (rotated if compromised) OpenRouter dashboard Hermes GitHub Code PAT ~/.hermes/.env ← BitwardenSelected fducat18 repos only contents:write, pull_requests:write90 days (with weekly expiry check) GitHub Settings → PATs Hermes GitHub Backup PAT ~/.hermes/.env ← Bitwardenhermes-backups repo ONLYcontents:write90 days (with weekly expiry check) GitHub Settings → PATs
Token Why Professional GitHub PAT (francoiducat)Professional Boundary — no pro token on VPS. Hermes has no access to Decathlon org repos. Google OAuth2 refresh token (Djuly’s family) Family Boundary — family Drive mount is roadmap only. Google OAuth2 refresh token (Professional) Professional Boundary — no pro Drive mount on VPS. Pro email (Gmail/SMTP) Gmail access not yet enabled. Roadmap only (ADR-0007 upgrade path). Bank/financial API keys Completely out of scope.
A weekly cron job (Monday 09:00) checks GitHub PAT expiry and notifies François via Slack if any token expires within 7 days.
Property Detail Zero inbound ports (except SSH/22)Cloudflare Tunnel is outbound-only. VPS initiates connection to Cloudflare edge. All web traffic authenticated Cloudflare Access (email OTP) gates hermes.ducatillon.net and docs.ducatillon.net Slack webhook secured Delivered through the tunnel, not a public HTTP endpoint VPS IP isolated VPS has its own public IP in Hostinger’s datacenter. Not your home network. Docs site independent If VPS dies, docs.ducatillon.net stays up (Cloudflare Pages is separate infrastructure)
Component Detail Daemon cloudflared running as systemd serviceTunnel name hermes-<provider>Public route hermes.ducatillon.net → http://localhost:9119Auth Cloudflare Access — email OTP to François’s personal email DNS CNAME auto-created by Cloudflare Tunnel token Stored in cloudflared config (not in .env)
Data Visible to Cloudflare? Risk HTTPS traffic content ❌ No (TLS between browser and Cloudflare, and between Cloudflare and VPS via tunnel) None DNS queries ✅ Yes (Cloudflare is the DNS provider) Low — domain names only Access auth events ✅ Yes (email OTP verification) Low — standard auth logs Tunnel metadata ✅ Yes (connection timestamps, bytes transferred) Low — no content
Data Frequency Method Encrypted? Contains secrets? sessions.dbDaily age → GitHub backup repo✅ Yes ❌ No memory.dbDaily age → GitHub backup repo✅ Yes ❌ No skills/Daily age → GitHub backup repo✅ Yes ❌ No cron/Daily age → GitHub backup repo✅ Yes ❌ No config.yamlDaily age → GitHub backup repo✅ Yes ❌ No .envNEVER Re-provisioned from Bitwarden N/A ✅ Yes (that’s why it’s excluded) SOUL.md + CONTEXT.md N/A Source is GitHub repo N/A ❌ No
Control Detail Encryption age with public recipient. Private key stored in Bitwarden ONLY.Backup repo Private GitHub repo (hermes-backups). Only encrypted .age archives. Backup PAT HERMES_BACKUP_GITHUB_PAT scoped to hermes-backups repo only.Offline copy Weekly copy to External HD (manual by François). Decryption key age private identity in Bitwarden. NOT on GitHub. NOT in the backup repo.
Scenario RTO RPO Procedure VPS gone (Hostinger) ~60 min 1 day Provision new VPS → install Hermes → restore from encrypted backup → re-provision .env from Bitwarden GitHub gone N/A 1 week Restore from External HD (offline copy) Bitwarden gone N/A N/A Bitwarden has its own cloud sync + recovery. François has the master password. Cloudflare gone ~30 min 0 Point DNS to new tunnel provider. Docs site on Cloudflare Pages would need migration.
Category Examples Where they belong API keys OPENROUTER_API_KEY, SLACK_BOT_TOKEN~/.hermes/.env only (copied from Bitwarden)GitHub PATs HERMES_GITHUB_CODE_PAT~/.hermes/.env onlySSH private keys hostinger_vps_ed25519Local ~/.ssh/ + Bitwarden OAuth refresh tokens Google Drive OAuth2 ~/.hermes/.env onlyPasswords Any service password Bitwarden only .env fileThe file itself Never in git. Only .env.example with placeholders.
Repo Visibility Who can access Hermes access fducat18/docs.ducatillon.netPrivate François (SSH + web) GitHub MCP (read .md) fducat18/hermes-backupsPrivate François (SSH + web) HERMES_BACKUP_GITHUB_PAT (write only)fducat18/* (code projects)Private François (SSH + web) HERMES_GITHUB_CODE_PAT (selected repos)francoiducat/* (pro)Org-private François (M3Pro only) ❌ No access. No pro token on VPS.
Risk Mitigation Malicious dependency in Hermes Hermes is open-source (NousResearch/hermes-agent). François can audit. Compromised pip package pipx installs in isolated venv.Compromised npm package Hermes uses npm for tools (not for its own runtime). GitHub account compromise 2FA on GitHub (Aegis Authenticator). PATs are fine-grained and scoped.
This checklist is designed to be run periodically (or via automated cron) to verify that the security posture has not degraded.
Gap Severity Action Status fail2ban not installed 🟡 Medium Install and configure for SSH Open Hermes gateway service inactive 🔴 High Investigate why systemctl --user status hermes-gateway shows inactive Needs immediate attention Dashboard bound to 0.0.0.0:9119 🔴 High Fix dashboard to bind to 127.0.0.1 only ✅ Fixed 2026-05-31: service updated, HERMES_DASHBOARD_TRUSTED_HOSTS added for tunnel hostname No automated security audit 🟡 Medium Implement the security audit script (see below) ✅ Fixed 2026-05-31