Skip to content

Security Model — Per-Layer Reference

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.

Layer 3 — Hermes Setup (Install + Config)

Section titled “Layer 3 — Hermes Setup (Install + Config)”
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
│
│ (manual copy by SSH)
▼
~/.hermes/.env on Agent Host
│
│ (read at startup)
▼
Hermes Agent runtime
  • .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).
Terminal window
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.

  1. Least scope — each token gets ONLY the permissions it needs
  2. Short-lived — 90-day expiry when tolerable
  3. Per-purpose — no wildcard repo scope, no single token for everything
  4. Stored in Bitwarden — never in plaintext in git, docs, or logs
  5. 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.


Layer 6 — Network (Cloudflare Tunnel + Access)

Section titled “Layer 6 — Network (Cloudflare Tunnel + Access)”
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.

Layer 8 — Docs & Repos (Information Leakage Prevention)

Section titled “Layer 8 — Docs & Repos (Information Leakage Prevention)”
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.

  • PasswordAuthentication no in /etc/ssh/sshd_config
  • PermitRootLogin no in /etc/ssh/sshd_config
  • UFW active, only port 22 allowed inbound
  • unattended-upgrades installed and active
  • fail2ban installed and active (currently missing — action item)
  • No unnecessary services listening on public interfaces
  • Kernel is current (apt list --upgradable shows nothing critical)
  • ~/.hermes/.env permissions are 600
  • ~/.hermes/.env owned by hermes:hermes
  • ~/.hermes/.env is NOT in any git repo (.gitignore or simply not tracked)
  • hermes-gateway service is running
  • hermes-gateway runs as hermes user (not root)
  • Dashboard binds to 127.0.0.1 only (not 0.0.0.0)
  • All PATs are fine-grained (not classic)
  • All PATs have minimum required scope
  • No PAT has wildcard repo scope
  • No PAT expires within 7 days (weekly cron handles this)
  • OpenRouter credit limit is set (€15/month)
  • No professional tokens on the Agent Host
  • cloudflared service is active
  • Cloudflare Access is enabled on hermes.ducatillon.net
  • Cloudflare Access is enabled on docs.ducatillon.net
  • No inbound ports except 22 (ss -tlnp to verify)
  • Slack webhook is delivered through tunnel (not public endpoint)
  • Daily encrypted backup ran in the last 24 hours
  • Backup repo contains only .age files (no plaintext)
  • .env is NOT in the backup
  • age private key is in Bitwarden (not on VPS)
  • Weekly offline backup to External HD is current
  • No professional Google Drive token on VPS
  • No family Google Drive token on VPS
  • No Gmail/email access enabled
  • No bank/financial API access
  • Docs repo contains no secrets (scan for key patterns)

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