From c6419d5fa27bacea009ffe2d19e007151556ceb4 Mon Sep 17 00:00:00 2001 From: "Ioannis L." <44038245+blitzcrieg1@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:07:14 +0300 Subject: [PATCH 1/2] refactor(config): separate SIEM settings from the optional runtime A security engineer cloning the repo saw ~30 settings about Qdrant, Ollama, Gemini, Postgres, Telegram and Gmail interleaved with the audit config, and reasonably concluded the project was half-abandoned or hiding something. That is a credibility tax on exactly the audience the recorder is for. Pure reorder: the SIEM flight recorder settings (identity, trail, sinks, DLP, tool policy, detection) now come first under a clear header, and the optional governed runtime (vault, model providers, channel drivers) is grouped below with a comment pointing at docs/advanced-governed-runtime.md. Nothing is deleted, so the runtime keeps working; a recorder-only deployment can ignore the second block entirely. No behaviour change: all 64 fields, defaults, and validation aliases verified byte-identical to before via model_dump + model_fields comparison. Co-Authored-By: Claude Opus 4.8 --- apps/orchestrator/core/config.py | 184 ++++++++++++++++++------------- 1 file changed, 108 insertions(+), 76 deletions(-) diff --git a/apps/orchestrator/core/config.py b/apps/orchestrator/core/config.py index 77c6f63..fd7dbd2 100644 --- a/apps/orchestrator/core/config.py +++ b/apps/orchestrator/core/config.py @@ -7,6 +7,19 @@ class Settings(BaseSettings): + """Runtime configuration. + + Two groups, and the split is deliberate. The **SIEM flight recorder** is the + product: capture, canonical schema, detection, DLP, tool policy, and the + forward sinks. Everything a security engineer touches lives in that block. + + The **optional governed runtime** below it (Obsidian vault + LangGraph + skills, Gemini/Ollama, the Telegram/Gmail channel drivers) is a separate, + off-by-default lineage documented in ``docs/advanced-governed-runtime.md``. + It is not required for IDE-hook or MCP capture. It is kept, not dead, but a + cloner evaluating the recorder can ignore it entirely. + """ + model_config = SettingsConfigDict( env_prefix="AGENTMETRY_", env_file=_ORCHESTRATOR_ROOT / ".env", @@ -14,65 +27,21 @@ class Settings(BaseSettings): extra="ignore", ) - vault_path: Path = Path(__file__).resolve().parents[3] / "vault" - qdrant_url: str = "http://localhost:6333" - ollama_base_url: str = "http://localhost:11434" - ollama_model: str = "llama3.2" - embedding_model: str = "nomic-embed-text" - llm_provider: str = "gemini" # gemini | ollama | mock - allow_mock: bool = False # permit mock fallback when no real provider is available - gemini_api_key: str = Field( + # ------------------------------------------------------------------ SIEM + # Operator identity + optional API key protecting ingest/tail/export. + operator_id: str = Field( default="", validation_alias=AliasChoices( - "GEMINI_API_KEY", - "GOOGLE_API_KEY", + "OPERATOR_ID", + "AGENTMETRY_OPERATOR_ID", ), ) - gemini_model: str = "gemini-2.5-flash-lite" - gemini_embedding_model: str = "gemini-embedding-2" - embedding_dimensions: int = 768 - collection_name: str = "agent_memory" - database_url: str = "sqlite:///./data/telemetry.db" - postgres_url: str = "postgresql://agentmetry:agentmetry@localhost:5432/agentic_os" - use_postgres: bool = False - approval_threshold: float = 0.9 - cost_alert_threshold: float = 1.0 api_key: str = Field( default="", validation_alias=AliasChoices("AGENTMETRY_API_KEY"), ) - context_window_tokens: int = 1_048_576 - gemini_health_cache_seconds: int = 300 - gemini_health_probe: bool = False - gemini_embed_min_interval_seconds: float = 0.7 - # Pacing: 60 / RPM. Flash-lite free tier ≈10 RPM → 6s; 2.5-flash ≈5 RPM → 13s. - gemini_flash_min_interval_seconds: float = 6.5 - gemini_flash_daily_limit: int = 20 - gemini_flash_interactive_reserve: int = 8 - kernel_background_run_limit: int = 2 - sandbox_tier1_allowed: str = "git" # comma-separated binaries runnable in the jail - # Telegram channel adapter — ships disabled, like the gmail/search drivers. - channel_telegram_enabled: bool = False - telegram_bot_token: str = Field( - default="", - validation_alias=AliasChoices( - "TELEGRAM_BOT_TOKEN", - ), - ) - telegram_allowed_chat_ids: str = "" # comma-separated; empty refuses to start - startup_vault_index: bool = True - startup_index_skip_unchanged: bool = True - active_loop_archive_days: int = 7 - active_loop_auto_archive: bool = True - gmail_send_enabled: bool = False # Phase 4-E — requires explicit unlock - # Agentmetry — operator identity + SIEM-ready JSONL forwarder - operator_id: str = Field( - default="", - validation_alias=AliasChoices( - "OPERATOR_ID", - "AGENTMETRY_OPERATOR_ID", - ), - ) + + # Canonical JSONL trail + query index (system of record for the hook path). audit_export_enabled: bool = Field( default=True, validation_alias=AliasChoices( @@ -82,28 +51,10 @@ class Settings(BaseSettings): audit_export_path: Path = _ORCHESTRATOR_ROOT / "data" / "audit-forward.jsonl" audit_db_path: Path = _ORCHESTRATOR_ROOT / "data" / "audit.db" detection_live_db_path: Path = _ORCHESTRATOR_ROOT / "data" / "detection_live.db" - # Local policy checks (core/audit/policy.py). Off by default: the built-in - # ruleset is a hardcoded starting point, and it annotates only, it cannot - # block. Real prevention lives in the hook (DLP block mode). - policy_enabled: bool = Field( - default=False, - validation_alias=AliasChoices("AGENTMETRY_POLICY_ENABLED"), - ) - # off-hours-activity detection. Off by default: "unusual hours" is only a - # signal once an operator says which hours are usual, and scheduled jobs - # legitimately run at night. - detect_off_hours: bool = Field( - default=False, - validation_alias=AliasChoices("AGENTMETRY_DETECT_OFF_HOURS"), - ) - business_hours: str = Field( - default="09-18", # local start-end, 24h - validation_alias=AliasChoices("AGENTMETRY_BUSINESS_HOURS"), - ) - business_tz: str = Field( - default="UTC", # IANA name, e.g. Europe/Athens - validation_alias=AliasChoices("AGENTMETRY_BUSINESS_TZ"), - ) + audit_ingest_enabled: bool = True + audit_ingest_url: str = "http://127.0.0.1:8000" + + # Forward sinks (all optional; file is the default and never a cloud ledger). audit_sink: str = Field( default="file", validation_alias=AliasChoices("AGENTMETRY_AUDIT_SINK"), @@ -146,21 +97,21 @@ class Settings(BaseSettings): audit_splunk_index: str = "main" audit_splunk_sourcetype: str = "agentmetry:json" audit_splunk_verify_tls: bool = True - audit_ingest_enabled: bool = True - audit_ingest_url: str = "http://127.0.0.1:8000" audit_alert_webhook_url: str = Field( default="", validation_alias=AliasChoices( "AGENTMETRY_AUDIT_ALERT_WEBHOOK_URL", ), ) - # Semantic DLP + + # Semantic DLP — regex scan of tool arguments at the hook boundary. dlp_mode: str = Field( default="log", validation_alias=AliasChoices("AGENTMETRY_DLP_MODE"), ) dlp_rules_path: Path = _ORCHESTRATOR_ROOT.parent.parent / "policies" / "dlp" / "manifest.yaml" dlp_pii: bool = True + # Tool allow/deny policy — enforced at the hook boundary (like DLP block mode). tool_policy_mode: str = Field( default="log", @@ -170,6 +121,87 @@ class Settings(BaseSettings): _ORCHESTRATOR_ROOT.parent.parent / "policies" / "tool" / "manifest.yaml" ) + # Post-ingest policy checks (core/audit/policy.py). Off by default: the + # built-in ruleset is a hardcoded starting point, and it annotates only, it + # cannot block. Real prevention lives in the hook (DLP block mode). + policy_enabled: bool = Field( + default=False, + validation_alias=AliasChoices("AGENTMETRY_POLICY_ENABLED"), + ) + # off-hours-activity detection. Off by default: "unusual hours" is only a + # signal once an operator says which hours are usual, and scheduled jobs + # legitimately run at night. + detect_off_hours: bool = Field( + default=False, + validation_alias=AliasChoices("AGENTMETRY_DETECT_OFF_HOURS"), + ) + business_hours: str = Field( + default="09-18", # local start-end, 24h + validation_alias=AliasChoices("AGENTMETRY_BUSINESS_HOURS"), + ) + business_tz: str = Field( + default="UTC", # IANA name, e.g. Europe/Athens + validation_alias=AliasChoices("AGENTMETRY_BUSINESS_TZ"), + ) + + # ------------------------------------ optional governed runtime (advanced) + # Obsidian vault + LangGraph skills, model providers, and channel drivers. + # Off by default and not required for SIEM capture. See + # docs/advanced-governed-runtime.md. Kept for governed-agent demos; a + # recorder-only deployment can leave this whole block at its defaults. + vault_path: Path = Path(__file__).resolve().parents[3] / "vault" + startup_vault_index: bool = True + startup_index_skip_unchanged: bool = True + active_loop_archive_days: int = 7 + active_loop_auto_archive: bool = True + kernel_background_run_limit: int = 2 + sandbox_tier1_allowed: str = "git" # comma-separated binaries runnable in the jail + approval_threshold: float = 0.9 + cost_alert_threshold: float = 1.0 + context_window_tokens: int = 1_048_576 + + # Model providers (governed runtime only). + llm_provider: str = "gemini" # gemini | ollama | mock + allow_mock: bool = False # permit mock fallback when no real provider is available + qdrant_url: str = "http://localhost:6333" + ollama_base_url: str = "http://localhost:11434" + ollama_model: str = "llama3.2" + embedding_model: str = "nomic-embed-text" + gemini_api_key: str = Field( + default="", + validation_alias=AliasChoices( + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + ), + ) + gemini_model: str = "gemini-2.5-flash-lite" + gemini_embedding_model: str = "gemini-embedding-2" + embedding_dimensions: int = 768 + collection_name: str = "agent_memory" + gemini_health_cache_seconds: int = 300 + gemini_health_probe: bool = False + gemini_embed_min_interval_seconds: float = 0.7 + # Pacing: 60 / RPM. Flash-lite free tier ≈10 RPM → 6s; 2.5-flash ≈5 RPM → 13s. + gemini_flash_min_interval_seconds: float = 6.5 + gemini_flash_daily_limit: int = 20 + gemini_flash_interactive_reserve: int = 8 + + # Runtime telemetry store (separate from the audit trail above). + database_url: str = "sqlite:///./data/telemetry.db" + postgres_url: str = "postgresql://agentmetry:agentmetry@localhost:5432/agentic_os" + use_postgres: bool = False + + # Channel drivers — ship disabled, require explicit unlock. + channel_telegram_enabled: bool = False + telegram_bot_token: str = Field( + default="", + validation_alias=AliasChoices( + "TELEGRAM_BOT_TOKEN", + ), + ) + telegram_allowed_chat_ids: str = "" # comma-separated; empty refuses to start + gmail_send_enabled: bool = False # Phase 4-E — requires explicit unlock + settings = Settings() From 39070fb6a90401dba1ecea0f661f16a4aab0b9d8 Mon Sep 17 00:00:00 2001 From: "Ioannis L." <44038245+blitzcrieg1@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:07:16 +0300 Subject: [PATCH 2/2] fix(auth): constant-time API key comparison require_api_key and verify_ws_token compared the shared key with plain `==`, which short-circuits on the first differing byte and leaks the key's prefix through response timing to anyone who can reach the endpoint. Low risk on loopback, real the moment the dashboard is bound to a LAN. Compare with secrets.compare_digest via a small helper, coercing a missing token to "" so a None header cannot raise. Behaviour is otherwise unchanged: unset key still skips auth, valid key passes, invalid rejects. Co-Authored-By: Claude Opus 4.8 --- apps/orchestrator/core/auth.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/orchestrator/core/auth.py b/apps/orchestrator/core/auth.py index cd9d4b4..5ad598a 100644 --- a/apps/orchestrator/core/auth.py +++ b/apps/orchestrator/core/auth.py @@ -2,11 +2,23 @@ from __future__ import annotations +import secrets + from fastapi import HTTPException, Request, Security from fastapi.security import APIKeyHeader from core.config import settings + +def _token_matches(token: str | None) -> bool: + """Constant-time compare so the key can't be recovered a byte at a time. + + A plain `==` short-circuits on the first differing byte, leaking the shared + key's prefix through response timing to anyone who can reach the endpoint. + `compare_digest` runs in time independent of where the mismatch is. + """ + return secrets.compare_digest(token or "", settings.api_key) + _api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) @@ -24,7 +36,7 @@ async def require_api_key( if auth.lower().startswith("bearer "): token = auth[7:].strip() - if token != settings.api_key: + if not _token_matches(token): raise HTTPException(status_code=401, detail="Invalid or missing API key") @@ -39,4 +51,4 @@ def verify_ws_token(query_token: str | None, request: Request) -> bool: auth = request.headers.get("Authorization", "") if auth.lower().startswith("bearer "): token = auth[7:].strip() - return token == settings.api_key + return _token_matches(token)