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). Dedicated kernel and 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_config
Root login disabled
✅
PermitRootLogin no
ed25519 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 enable
Inbound: 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/.env
600
hermes:hermes
API keys, tokens — readable only by hermes user
~/.hermes/config.yaml
600
hermes:hermes
Hermes configuration (model, gateway, providers)
~/.hermes/SOUL.md
644
hermes:hermes
Agent identity context (non-secret)
~/.hermes/CONTEXT.md
644
hermes:hermes
Domain glossary (non-secret)
~/.hermes/sessions.db
600
hermes:hermes
Conversation history (SQLite)
~/.hermes/memory.db
600
hermes:hermes
Cross-session memory (SQLite)
~/.hermes/skills/
755 / 644
hermes:hermes
Learned procedures (non-secret)
~/.hermes/.ssh/ (if any)
700 / 600
hermes:hermes
SSH 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_TOKEN
Slack gateway authentication
Send messages, read channels
OPENROUTER_API_KEY
LLM API calls
Inference on 200+ models
HERMES_GITHUB_CODE_PAT
GitHub code operations
Selected fducat18 repos only (contents:write, pull_requests:write)
HERMES_BACKUP_GITHUB_PAT
Push 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. Default remains OpenRouter ZDR (accepted risk). Local model (llama.cpp on M1Max via Tailscale — local-llm-routing ) is an experimentation tier, used only on explicit “use local for this”.
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 ← Bitwarden
Slack workspace
bot, chat:write, channels:read, channels:history (minimum needed)
Slack-dependent
Slack app dashboard
OpenRouter API key
~/.hermes/.env ← Bitwarden
OpenRouter
Inference only
No expiry (rotated if compromised)
OpenRouter dashboard
Hermes GitHub Code PAT
~/.hermes/.env ← Bitwarden
Selected fducat18 repos only
contents:write, pull_requests:write
90 days (with weekly expiry check)
GitHub Settings → PATs
Hermes GitHub Backup PAT
~/.hermes/.env ← Bitwarden
hermes-backups repo ONLY
contents:write
90 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 a 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 service
Tunnel name
hermes-<provider>
Public route
hermes.ducatillon.net → http://localhost:9119
Auth
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.db
Daily
age → GitHub backup repo
✅ Yes
❌ No
memory.db
Daily
age → GitHub backup repo
✅ Yes
❌ No
skills/
Daily
age → GitHub backup repo
✅ Yes
❌ No
cron/
Daily
age → GitHub backup repo
✅ Yes
❌ No
config.yaml
Daily
age → GitHub backup repo
✅ Yes
❌ No
.env
NEVER
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 only
SSH private keys
hostinger_vps_ed25519
Local ~/.ssh/ + Bitwarden
OAuth refresh tokens
Google Drive OAuth2
~/.hermes/.env only
Passwords
Any service password
Bitwarden only
.env file
The file itself
Never in git. Only .env.example with placeholders.
Repo
Visibility
Who can access
Hermes access
fducat18/docs.ducatillon.net
Private
François (SSH + web)
GitHub MCP (read .md)
fducat18/hermes-backups
Private
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