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.

LayerWhat’s protectedSSOT for secrets
1. VPS Host (Hostinger KVM2)Physical/hypervisor, network perimeterN/A (provider)
2. VPS OS (Ubuntu 24.04 LTS)SSH, filesystem, processes, firewallN/A (OS-level)
3. Hermes Setup (install + config)Service isolation, file permissions, .envBitwarden (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, rotationBitwarden (renewal point)
6. Network (Cloudflare Tunnel)Transport, exposure, authenticationCloudflare Access
7. Backup & RecoveryState durability, secret exclusionBitwarden + CF Access email
8. Docs & ReposInformation leakage, supply chainGitHub (private repos)

ControlStatusDetail
Provider trustAccepted riskHostinger = owner of hypervisor. Same trust model as any VPS/cloud. No Hostinger-specific APIs or tools used.
KVM virtualizationFull kernel-level isolation (not container-based). Your own kernel, your own userspace.
Provider agnosticismHermes 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.

ControlStatusDetail
Key-only authenticationPasswordAuthentication no in /etc/ssh/sshd_config
Root login disabledPermitRootLogin no
ed25519 key typeGenerated: ssh-keygen -t ed25519 -C "francois@ducatillon.net-hostinger-vps"
Dedicated hermes useruseradd -m -s /bin/bash hermes. Not root, but has passwordless sudo.
Private key storageBacked up to Bitwarden (Credential Vault). Only on M1Max + Hostinger VPS.
SSH configUses ~/.ssh/config with Host hostinger-vps alias. Key file: ~/.ssh/hostinger_vps_ed25519
ControlStatusDetail
UFW enabledufw allow 22/tcp && ufw --force enable
Inbound: port 22 onlyAll other ports blocked. Cloudflare Tunnel is outbound-only.
No open web portsDashboard (9119), 9router (3000), and Slack webhook all behind Cloudflare Tunnel.
ControlStatusDetail
unattended-upgradesInstalled and configured for security updates.
Kernel patchesApplied via apt upgrade (requires reboot).
ControlStatusDetail
fail2banMissingNot 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).

ControlStatusDetail
Hermes runs as hermes userhermes-gateway runs as user service (systemctl --user).
Restart=on-failureService 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)”
PathPermissionsOwnerPurpose
~/.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
│ (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)

SecretUsed forScope
SLACK_BOT_TOKENSlack gateway authenticationSend messages, read channels
OPENROUTER_API_KEYLLM API callsInference on 200+ models
HERMES_GITHUB_CODE_PATGitHub code operationsSelected fducat18 repos only (contents:write, pull_requests:write)
HERMES_BACKUP_GITHUB_PATPush encrypted backupshermes-backups repo only (contents:write)
ConcernMitigation
API data sent to OpenRouterOpenRouter 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 modelsBoth go through OpenRouter. Same data handling policies.
Budget cap€15/month (adjustable by François). Set on OpenRouter dashboard.
GuardrailImplementation
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 pushHermes CANNOT push to main, delete branches, force-push, or create/delete repos.
Secret redaction in logsHermes 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
TokenStorageScopePermissionsExpiryRevocation
Slack bot token~/.hermes/.env ← BitwardenSlack workspacebot, chat:write, channels:read, channels:history (minimum needed)Slack-dependentSlack app dashboard
OpenRouter API key~/.hermes/.env ← BitwardenOpenRouterInference onlyNo expiry (rotated if compromised)OpenRouter dashboard
Hermes GitHub Code PAT~/.hermes/.env ← BitwardenSelected fducat18 repos onlycontents: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
TokenWhy
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 keysCompletely 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)”
PropertyDetail
Zero inbound ports (except SSH/22)Cloudflare Tunnel is outbound-only. VPS initiates connection to Cloudflare edge.
All web traffic authenticatedCloudflare Access (email OTP) gates hermes.ducatillon.net and docs.ducatillon.net
Slack webhook securedDelivered through the tunnel, not a public HTTP endpoint
VPS IP isolatedVPS has its own public IP in Hostinger’s datacenter. Not your home network.
Docs site independentIf VPS dies, docs.ducatillon.net stays up (Cloudflare Pages is separate infrastructure)
ComponentDetail
Daemoncloudflared running as systemd service
Tunnel namehermes-<provider>
Public routehermes.ducatillon.nethttp://localhost:9119
AuthCloudflare Access — email OTP to François’s personal email
DNSCNAME auto-created by Cloudflare
Tunnel tokenStored in cloudflared config (not in .env)
DataVisible 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

DataFrequencyMethodEncrypted?Contains secrets?
sessions.dbDailyage → GitHub backup repo✅ Yes❌ No
memory.dbDailyage → GitHub backup repo✅ Yes❌ No
skills/Dailyage → GitHub backup repo✅ Yes❌ No
cron/Dailyage → GitHub backup repo✅ Yes❌ No
config.yamlDailyage → GitHub backup repo✅ Yes❌ No
.envNEVERRe-provisioned from BitwardenN/A✅ Yes (that’s why it’s excluded)
SOUL.md + CONTEXT.mdN/ASource is GitHub repoN/A❌ No
ControlDetail
Encryptionage with public recipient. Private key stored in Bitwarden ONLY.
Backup repoPrivate GitHub repo (hermes-backups). Only encrypted .age archives.
Backup PATHERMES_BACKUP_GITHUB_PAT scoped to hermes-backups repo only.
Offline copyWeekly copy to External HD (manual by François).
Decryption keyage private identity in Bitwarden. NOT on GitHub. NOT in the backup repo.
ScenarioRTORPOProcedure
VPS gone (Hostinger)~60 min1 dayProvision new VPS → install Hermes → restore from encrypted backup → re-provision .env from Bitwarden
GitHub goneN/A1 weekRestore from External HD (offline copy)
Bitwarden goneN/AN/ABitwarden has its own cloud sync + recovery. François has the master password.
Cloudflare gone~30 min0Point 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)”
CategoryExamplesWhere they belong
API keysOPENROUTER_API_KEY, SLACK_BOT_TOKEN~/.hermes/.env only (copied from Bitwarden)
GitHub PATsHERMES_GITHUB_CODE_PAT~/.hermes/.env only
SSH private keyshostinger_vps_ed25519Local ~/.ssh/ + Bitwarden
OAuth refresh tokensGoogle Drive OAuth2~/.hermes/.env only
PasswordsAny service passwordBitwarden only
.env fileThe file itselfNever in git. Only .env.example with placeholders.
RepoVisibilityWho can accessHermes access
fducat18/docs.ducatillon.netPrivateFrançois (SSH + web)GitHub MCP (read .md)
fducat18/hermes-backupsPrivateFrançois (SSH + web)HERMES_BACKUP_GITHUB_PAT (write only)
fducat18/* (code projects)PrivateFrançois (SSH + web)HERMES_GITHUB_CODE_PAT (selected repos)
francoiducat/* (pro)Org-privateFrançois (M3Pro only)❌ No access. No pro token on VPS.
RiskMitigation
Malicious dependency in HermesHermes is open-source (NousResearch/hermes-agent). François can audit.
Compromised pip packagepipx installs in isolated venv.
Compromised npm packageHermes uses npm for tools (not for its own runtime).
GitHub account compromise2FA 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)

GapSeverityActionStatus
fail2ban not installed🟡 MediumInstall and configure for SSHOpen
Hermes gateway service inactive🔴 HighInvestigate why systemctl --user status hermes-gateway shows inactiveNeeds immediate attention
Dashboard bound to 0.0.0.0:9119🔴 HighFix 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🟡 MediumImplement the security audit script (see below)✅ Fixed 2026-05-31