[sovereignty] Cut the Cloud Umbilical — Close #94
THE BUG ======= Issue #94 flagged: the active config's fallback_model pointed to Google Gemini cloud. The enabled Health Monitor cron job had model=null, provider=null — so it inherited whatever the config defaulted to. If the default was ever accidentally changed back to cloud, every 5-minute cron tick would phone home. THE FIX ======= config.yaml: - fallback_model → local Ollama (hermes3:latest on localhost:11434) - Google Gemini custom_provider → renamed '(emergency only)' - tts.openai.model → disabled (use edge TTS locally) cron/jobs.json: - Health Monitor → explicit model/provider/base_url fields - No enabled job can ever inherit cloud defaults again tests/test_sovereignty_enforcement.py (NEW — 13 tests): - Default model is localhost - Fallback model is localhost (the #94 fix) - No enabled cron has null model/provider - No enabled cron uses cloud URLs - First custom_provider is local - TTS and STT default to local tests/test_local_runtime_defaults.py (UPDATED): - Now asserts fallback is Ollama, not Gemini WHAT STILL WORKS ================ Google Gemini is still available for explicit override: hermes --model gemini-2.5-pro It's just not automatic anymore. You have to ask for it. FULL SUITE ========== 36/36 pass. Zero regressions. Closes #94 Signed-off-by: gemini <gemini@hermes.local>
This commit is contained in:
14
config.yaml
14
config.yaml
@@ -114,7 +114,7 @@ tts:
|
|||||||
voice_id: pNInz6obpgDQGcFmaJgB
|
voice_id: pNInz6obpgDQGcFmaJgB
|
||||||
model_id: eleven_multilingual_v2
|
model_id: eleven_multilingual_v2
|
||||||
openai:
|
openai:
|
||||||
model: gpt-4o-mini-tts
|
model: '' # disabled — use edge TTS locally
|
||||||
voice: alloy
|
voice: alloy
|
||||||
neutts:
|
neutts:
|
||||||
ref_audio: ''
|
ref_audio: ''
|
||||||
@@ -189,7 +189,9 @@ custom_providers:
|
|||||||
base_url: http://localhost:8081/v1
|
base_url: http://localhost:8081/v1
|
||||||
api_key: none
|
api_key: none
|
||||||
model: hermes4:14b
|
model: hermes4:14b
|
||||||
- name: Google Gemini
|
# ── Emergency cloud provider — not used by default or any cron job.
|
||||||
|
# Available for explicit override only: hermes --model gemini-2.5-pro
|
||||||
|
- name: Google Gemini (emergency only)
|
||||||
base_url: https://generativelanguage.googleapis.com/v1beta/openai
|
base_url: https://generativelanguage.googleapis.com/v1beta/openai
|
||||||
api_key_env: GEMINI_API_KEY
|
api_key_env: GEMINI_API_KEY
|
||||||
model: gemini-2.5-pro
|
model: gemini-2.5-pro
|
||||||
@@ -213,7 +215,7 @@ mcp_servers:
|
|||||||
env: {}
|
env: {}
|
||||||
timeout: 30
|
timeout: 30
|
||||||
fallback_model:
|
fallback_model:
|
||||||
provider: custom
|
provider: ollama
|
||||||
model: gemini-2.5-pro
|
model: hermes3:latest
|
||||||
base_url: https://generativelanguage.googleapis.com/v1beta/openai
|
base_url: http://localhost:11434/v1
|
||||||
api_key_env: GEMINI_API_KEY
|
api_key: ''
|
||||||
|
|||||||
@@ -60,6 +60,9 @@
|
|||||||
"id": "a77a87392582",
|
"id": "a77a87392582",
|
||||||
"name": "Health Monitor",
|
"name": "Health Monitor",
|
||||||
"prompt": "Check Ollama is responding, disk space, memory, GPU utilization, process count",
|
"prompt": "Check Ollama is responding, disk space, memory, GPU utilization, process count",
|
||||||
|
"model": "hermes3:latest",
|
||||||
|
"provider": "ollama",
|
||||||
|
"base_url": "http://localhost:11434/v1",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"kind": "interval",
|
"kind": "interval",
|
||||||
"minutes": 5,
|
"minutes": 5,
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ def test_config_defaults_to_local_llama_cpp_runtime() -> None:
|
|||||||
)
|
)
|
||||||
assert local_provider["model"] == "hermes4:14b"
|
assert local_provider["model"] == "hermes4:14b"
|
||||||
|
|
||||||
assert config["fallback_model"]["provider"] == "custom"
|
assert config["fallback_model"]["provider"] == "ollama"
|
||||||
assert config["fallback_model"]["model"] == "gemini-2.5-pro"
|
assert config["fallback_model"]["model"] == "hermes3:latest"
|
||||||
|
assert "localhost" in config["fallback_model"]["base_url"]
|
||||||
|
|||||||
202
tests/test_sovereignty_enforcement.py
Normal file
202
tests/test_sovereignty_enforcement.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
"""Sovereignty enforcement tests.
|
||||||
|
|
||||||
|
These tests implement the acceptance criteria from issue #94:
|
||||||
|
[p0] Cut cloud inheritance from active harness config and cron
|
||||||
|
|
||||||
|
Every test in this file catches a specific way that cloud
|
||||||
|
dependency can creep back into the active config. If any test
|
||||||
|
fails, Timmy is phoning home.
|
||||||
|
|
||||||
|
These tests are designed to be run in CI and to BLOCK any commit
|
||||||
|
that reintroduces cloud defaults.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).parent.parent
|
||||||
|
CONFIG_PATH = REPO_ROOT / "config.yaml"
|
||||||
|
CRON_PATH = REPO_ROOT / "cron" / "jobs.json"
|
||||||
|
|
||||||
|
# Cloud URLs that should never appear in default/fallback paths
|
||||||
|
CLOUD_URLS = [
|
||||||
|
"generativelanguage.googleapis.com",
|
||||||
|
"api.openai.com",
|
||||||
|
"chatgpt.com",
|
||||||
|
"api.anthropic.com",
|
||||||
|
"openrouter.ai",
|
||||||
|
]
|
||||||
|
|
||||||
|
CLOUD_MODELS = [
|
||||||
|
"gpt-4",
|
||||||
|
"gpt-5",
|
||||||
|
"gpt-4o",
|
||||||
|
"claude",
|
||||||
|
"gemini",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config():
|
||||||
|
return yaml.safe_load(CONFIG_PATH.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cron_jobs():
|
||||||
|
data = json.loads(CRON_PATH.read_text())
|
||||||
|
return data.get("jobs", data) if isinstance(data, dict) else data
|
||||||
|
|
||||||
|
|
||||||
|
# ── Config defaults ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestDefaultModelIsLocal:
|
||||||
|
"""The default model must point to localhost."""
|
||||||
|
|
||||||
|
def test_default_model_is_not_cloud(self, config):
|
||||||
|
"""model.default should be a local model identifier."""
|
||||||
|
model = config["model"]["default"]
|
||||||
|
for cloud in CLOUD_MODELS:
|
||||||
|
assert cloud not in model.lower(), \
|
||||||
|
f"Default model '{model}' looks like a cloud model"
|
||||||
|
|
||||||
|
def test_default_base_url_is_localhost(self, config):
|
||||||
|
"""model.base_url should point to localhost."""
|
||||||
|
base_url = config["model"]["base_url"]
|
||||||
|
assert "localhost" in base_url or "127.0.0.1" in base_url, \
|
||||||
|
f"Default base_url '{base_url}' is not local"
|
||||||
|
|
||||||
|
def test_default_provider_is_local(self, config):
|
||||||
|
"""model.provider should be 'custom' or 'ollama'."""
|
||||||
|
provider = config["model"]["provider"]
|
||||||
|
assert provider in ("custom", "ollama", "local"), \
|
||||||
|
f"Default provider '{provider}' may route to cloud"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFallbackIsLocal:
|
||||||
|
"""The fallback model must also be local — this is the #94 fix."""
|
||||||
|
|
||||||
|
def test_fallback_base_url_is_localhost(self, config):
|
||||||
|
"""fallback_model.base_url must point to localhost."""
|
||||||
|
fb = config.get("fallback_model", {})
|
||||||
|
base_url = fb.get("base_url", "")
|
||||||
|
if base_url:
|
||||||
|
assert "localhost" in base_url or "127.0.0.1" in base_url, \
|
||||||
|
f"Fallback base_url '{base_url}' is not local — cloud leak!"
|
||||||
|
|
||||||
|
def test_fallback_has_no_cloud_url(self, config):
|
||||||
|
"""fallback_model must not contain any cloud API URLs."""
|
||||||
|
fb = config.get("fallback_model", {})
|
||||||
|
base_url = fb.get("base_url", "")
|
||||||
|
for cloud_url in CLOUD_URLS:
|
||||||
|
assert cloud_url not in base_url, \
|
||||||
|
f"Fallback model routes to cloud: {cloud_url}"
|
||||||
|
|
||||||
|
def test_fallback_model_name_is_local(self, config):
|
||||||
|
"""fallback_model.model should not be a cloud model name."""
|
||||||
|
fb = config.get("fallback_model", {})
|
||||||
|
model = fb.get("model", "")
|
||||||
|
for cloud in CLOUD_MODELS:
|
||||||
|
assert cloud not in model.lower(), \
|
||||||
|
f"Fallback model name '{model}' looks like cloud"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cron jobs ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestCronSovereignty:
|
||||||
|
"""Enabled cron jobs must never inherit cloud defaults."""
|
||||||
|
|
||||||
|
def test_enabled_crons_have_explicit_model(self, cron_jobs):
|
||||||
|
"""Every enabled cron job must have a non-null model field.
|
||||||
|
|
||||||
|
When model is null, the job inherits from config.yaml's default.
|
||||||
|
Even if the default is local today, a future edit could change it.
|
||||||
|
Explicit is always safer than implicit.
|
||||||
|
"""
|
||||||
|
for job in cron_jobs:
|
||||||
|
if not isinstance(job, dict):
|
||||||
|
continue
|
||||||
|
if not job.get("enabled", False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
model = job.get("model")
|
||||||
|
name = job.get("name", job.get("id", "?"))
|
||||||
|
assert model is not None and model != "", \
|
||||||
|
f"Enabled cron job '{name}' has null model — will inherit default"
|
||||||
|
|
||||||
|
def test_enabled_crons_have_explicit_provider(self, cron_jobs):
|
||||||
|
"""Every enabled cron job must have a non-null provider field."""
|
||||||
|
for job in cron_jobs:
|
||||||
|
if not isinstance(job, dict):
|
||||||
|
continue
|
||||||
|
if not job.get("enabled", False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
provider = job.get("provider")
|
||||||
|
name = job.get("name", job.get("id", "?"))
|
||||||
|
assert provider is not None and provider != "", \
|
||||||
|
f"Enabled cron job '{name}' has null provider — will inherit default"
|
||||||
|
|
||||||
|
def test_no_enabled_cron_uses_cloud_url(self, cron_jobs):
|
||||||
|
"""No enabled cron job should have a cloud base_url."""
|
||||||
|
for job in cron_jobs:
|
||||||
|
if not isinstance(job, dict):
|
||||||
|
continue
|
||||||
|
if not job.get("enabled", False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
base_url = job.get("base_url", "")
|
||||||
|
name = job.get("name", job.get("id", "?"))
|
||||||
|
for cloud_url in CLOUD_URLS:
|
||||||
|
assert cloud_url not in (base_url or ""), \
|
||||||
|
f"Cron '{name}' routes to cloud: {cloud_url}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Custom providers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestCustomProviders:
|
||||||
|
"""Cloud providers can exist but must not be the default path."""
|
||||||
|
|
||||||
|
def test_local_provider_exists(self, config):
|
||||||
|
"""At least one custom provider must be local."""
|
||||||
|
providers = config.get("custom_providers", [])
|
||||||
|
has_local = any(
|
||||||
|
"localhost" in p.get("base_url", "") or "127.0.0.1" in p.get("base_url", "")
|
||||||
|
for p in providers
|
||||||
|
)
|
||||||
|
assert has_local, "No local custom provider defined"
|
||||||
|
|
||||||
|
def test_first_provider_is_local(self, config):
|
||||||
|
"""The first custom_provider should be the local one.
|
||||||
|
|
||||||
|
Hermes resolves 'custom' provider by scanning the list in order.
|
||||||
|
If a cloud provider is listed first, it becomes the implicit default.
|
||||||
|
"""
|
||||||
|
providers = config.get("custom_providers", [])
|
||||||
|
if providers:
|
||||||
|
first = providers[0]
|
||||||
|
base_url = first.get("base_url", "")
|
||||||
|
assert "localhost" in base_url or "127.0.0.1" in base_url, \
|
||||||
|
f"First custom_provider '{first.get('name')}' is not local"
|
||||||
|
|
||||||
|
|
||||||
|
# ── TTS/STT ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestVoiceSovereignty:
|
||||||
|
"""Voice services should prefer local providers."""
|
||||||
|
|
||||||
|
def test_tts_default_is_local(self, config):
|
||||||
|
"""TTS provider should be local (edge or neutts)."""
|
||||||
|
tts_provider = config.get("tts", {}).get("provider", "")
|
||||||
|
assert tts_provider in ("edge", "neutts", "local"), \
|
||||||
|
f"TTS provider '{tts_provider}' may use cloud"
|
||||||
|
|
||||||
|
def test_stt_default_is_local(self, config):
|
||||||
|
"""STT provider should be local."""
|
||||||
|
stt_provider = config.get("stt", {}).get("provider", "")
|
||||||
|
assert stt_provider in ("local", "whisper", ""), \
|
||||||
|
f"STT provider '{stt_provider}' may use cloud"
|
||||||
Reference in New Issue
Block a user