#!/usr/bin/env python3 """ PRE_HERMES CP2A1 — Trusted Gate Evaluator (External Gate Enforcement Foundation). STRICTLY NON-PRODUCTIVE / NO AUTONOMY. Reine Gate-Entscheidungs-Boundary. Security-Prinzip: RED_QUEEN_DECISION != AUTHORIZATION RED_QUEEN_GATE_CHECK != TRUSTED_ENFORCEMENT Die Security Boundary liegt AUSSERHALB des hermes-red-queen Containers. Dieser Evaluator: - liest ausschliesslich den autoritativen CP1-State (/opt/control-plane/state/*) - liest current_boot_id frisch (/proc/sys/kernel/random/boot_id) - verwendet die bestehende CP1-Evaluator-Logik (control_reader.py aus der SoT) - berechnet den Effective-State - liefert eine kleine deterministische Gate-Entscheidung (ALLOW/DENY) - erzeugt Audit - fuehrt KEINE produktive Aktion aus - haelt KEINE mutierenden Credentials - hat KEINE Executor-Integration CP1-Logik-Reuse: importiert control_reader (SoT-Modul). KEINE Copy/Paste-Drift. Die ENV-basierten Pfade (C5_CONTROL_STATE_DIR / C5_BOOT_ID_FILE) werden HART UEBERSCHRIEBEN, damit RQ den Evaluator nicht dazu bringen kann, alternative State-Pfade zu lesen (user-supplied state path = verboten). Fail-closed: Jede Unklarheit -> DENY. Emergency unklar = ON. """ import json import os import sys import time import uuid from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer # --------------------------------------------------------------------------- # Hart verdrahtete autoritative Pfade (NICHT ENV-ueberschreibbar im Evaluator) # --------------------------------------------------------------------------- _DEFAULT_STATE_DIR = "/opt/control-plane/state" _DEFAULT_BOOT_ID_FILE = "/proc/sys/kernel/random/boot_id" AUTHORITATIVE_STATE_DIR = _DEFAULT_STATE_DIR AUTHORITATIVE_BOOT_ID_FILE = _DEFAULT_BOOT_ID_FILE AUDIT_DIR = "/audit" # --------------------------------------------------------------------------- # CP1-Logik-Reuse: SoT-Modul importieren und Pfade hart setzen # --------------------------------------------------------------------------- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import control_reader # noqa: E402 (SoT-Modul, eine semantische Wahrheit) # ENV-Ueberschreibbarkeit eliminieren: Pfade hart verdrahten. control_reader.STATE_DIR = AUTHORITATIVE_STATE_DIR control_reader.BOOT_ID_FILE = AUTHORITATIVE_BOOT_ID_FILE # --------------------------------------------------------------------------- # Statische Action-Class-Whitelist (NUR bekannte non-mutating Decision Classes) # --------------------------------------------------------------------------- ALLOWED_ACTION_CLASSES = frozenset( { "AUTONOMOUS_TICK_START", "AUTONOMOUS_TICK_CONTINUE", } ) # Noch NICHT erlaubt (spaetere Phasen): # SAVE, DELETE, FORGEJO_WRITE, NOTION_WRITE, TELEGRAM_SEND, # HOST_MUTATION, EXTERNAL_MUTATION # --------------------------------------------------------------------------- # Reason Codes (deterministisch, KEINE freien LLM-Texte als Security Decision) # --------------------------------------------------------------------------- RC_ALLOW = "ALLOW" RC_GLOBAL_AUTONOMY_OFF = "GLOBAL_AUTONOMY_OFF" RC_EMERGENCY_ON = "EMERGENCY_ON" RC_BOOT_ID_MISMATCH = "BOOT_ID_MISMATCH" RC_STATE_MISSING = "STATE_MISSING" RC_STATE_MALFORMED = "STATE_MALFORMED" RC_BOOT_ID_ERROR = "BOOT_ID_ERROR" RC_UNKNOWN_ACTION_CLASS = "UNKNOWN_ACTION_CLASS" RC_INVALID_REQUEST = "INVALID_REQUEST" RC_INTERNAL_ERROR = "INTERNAL_ERROR" # Limits MAX_REQUEST_BYTES = 4096 RATE_LIMIT_WINDOW_SEC = 60 RATE_LIMIT_MAX_REQUESTS = 120 # pro Window (RQ-Zugriff) # --------------------------------------------------------------------------- # Audit (append-only, gegen RQ-Manipulation geschuetzt: eigenes Volume) # --------------------------------------------------------------------------- def _now_iso(): return datetime.now(timezone.utc).isoformat() def _audit(entry: dict): """Schreibt einen Audit-Eintrag append-only. Fail-open fuer Audit (kein Security-Decision).""" try: os.makedirs(AUDIT_DIR, exist_ok=True) path = os.path.join(AUDIT_DIR, "gate_audit.log") line = json.dumps(entry, sort_keys=True) + "\n" with open(path, "a") as f: f.write(line) except Exception: # Audit-Fehler darf die Gate-Entscheidung nicht beeinflussen (fail-open fuer Audit). pass # --------------------------------------------------------------------------- # Rate Limiter (einfach, in-memory, pro Client-IP) # --------------------------------------------------------------------------- class _RateLimiter: def __init__(self, window_sec, max_requests): self.window_sec = window_sec self.max_requests = max_requests self._hits = {} # ip -> list[timestamp] def allow(self, ip: str) -> bool: now = time.time() cutoff = now - self.window_sec hits = [t for t in self._hits.get(ip, []) if t > cutoff] if len(hits) >= self.max_requests: self._hits[ip] = hits return False hits.append(now) self._hits[ip] = hits return True _rate_limiter = _RateLimiter(RATE_LIMIT_WINDOW_SEC, RATE_LIMIT_MAX_REQUESTS) # --------------------------------------------------------------------------- # Gate-Entscheidung (deterministisch, fail-closed) # --------------------------------------------------------------------------- def _effective_state(): """Berechnet den Effective-State via CP1-Logik. Fail-closed: Exception -> None.""" try: return control_reader.read_control_state() except Exception: return None def _tick_decision(state): """ AUTONOMOUS_TICK_START / CONTINUE = ALLOW nur wenn: GLOBAL_AUTONOMY_EFFECTIVE == ON AND EMERGENCY_EFFECTIVE == OFF Alles andere -> DENY mit deterministischem Reason Code. """ if state is None: return {"result": "DENY", "reason_code": RC_INTERNAL_ERROR} # Emergency: fail-closed, unklar = ON emergency_eff = state.get("emergency_effective") if emergency_eff != "OFF": return {"result": "DENY", "reason_code": RC_EMERGENCY_ON} # Boot-ID: muss vorhanden und plausibel sein (explizit, praeziser Reason) current_boot_id = state.get("current_boot_id") if not current_boot_id: return {"result": "DENY", "reason_code": RC_BOOT_ID_ERROR} # Global Autonomy: muss ON sein global_eff = state.get("global_autonomy_effective") if global_eff != "ON": return {"result": "DENY", "reason_code": RC_GLOBAL_AUTONOMY_OFF} return {"result": "ALLOW", "reason_code": RC_ALLOW} def _check_action(action_class: str): """Validiert Action Class und liefert die Gate-Entscheidung.""" if action_class not in ALLOWED_ACTION_CLASSES: return {"result": "DENY", "reason_code": RC_UNKNOWN_ACTION_CLASS} state = _effective_state() decision = _tick_decision(state) # Audit-Eintrag _audit( { "request_id": str(uuid.uuid4()), "timestamp": _now_iso(), "action_class": action_class, "result": decision["result"], "reason_code": decision["reason_code"], "current_boot_id": (state or {}).get("current_boot_id"), "effective_global": (state or {}).get("global_autonomy_effective"), "effective_emergency": (state or {}).get("emergency_effective"), } ) return decision # --------------------------------------------------------------------------- # Strict JSON-Schema-Validierung (keine unbekannten Felder, keine Duplikate) # --------------------------------------------------------------------------- def _validate_check_request(raw_body: bytes): """ Validiert den /check-Request streng. Erlaubt NUR: {"action_class": ""} Unbekannte Felder, Duplikate, null, falsche Typen -> INVALID_REQUEST. Rueckgabe: (action_class | None, error_reason | None) """ if not raw_body or len(raw_body) > MAX_REQUEST_BYTES: return None, RC_INVALID_REQUEST try: text = raw_body.decode("utf-8") except UnicodeDecodeError: return None, RC_INVALID_REQUEST try: obj = json.loads(text) except json.JSONDecodeError: return None, RC_INVALID_REQUEST if not isinstance(obj, dict): return None, RC_INVALID_REQUEST # Nur das Feld "action_class" erlaubt if set(obj.keys()) != {"action_class"}: return None, RC_INVALID_REQUEST action_class = obj.get("action_class") if not isinstance(action_class, str): return None, RC_INVALID_REQUEST if not action_class.strip(): return None, RC_INVALID_REQUEST return action_class, None # --------------------------------------------------------------------------- # HTTP Handler # --------------------------------------------------------------------------- class _Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def _send_json(self, status, payload): body = json.dumps(payload, sort_keys=True).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(body) def _client_ip(self): return self.client_address[0] if self.client_address else "unknown" def do_GET(self): ip = self._client_ip() if not _rate_limiter.allow(ip): self._send_json(429, {"error": "rate_limited"}) return if self.path == "/health": self._send_json(200, {"status": "ok", "service": "gate-evaluator"}) return if self.path == "/effective": state = _effective_state() if state is None: self._send_json(500, {"error": "internal_error"}) return # Observability-Projektion (KEINE Autoritaet) self._send_json(200, state) return self._send_json(404, {"error": "not_found"}) def do_POST(self): ip = self._client_ip() if not _rate_limiter.allow(ip): self._send_json(429, {"error": "rate_limited"}) return if self.path != "/check": self._send_json(404, {"error": "not_found"}) return # Request-Groesse begrenzen try: length = int(self.headers.get("Content-Length", "0")) except ValueError: self._send_json(400, {"result": "DENY", "reason_code": RC_INVALID_REQUEST}) return if length <= 0 or length > MAX_REQUEST_BYTES: self._send_json(400, {"result": "DENY", "reason_code": RC_INVALID_REQUEST}) return raw_body = self.rfile.read(length) action_class, err = _validate_check_request(raw_body) if err is not None: self._send_json(400, {"result": "DENY", "reason_code": err}) return decision = _check_action(action_class) status = 200 if decision["result"] == "ALLOW" else 403 self._send_json(status, decision) def log_message(self, fmt, *args): # Stille Logs (kein Request-Dump auf stdout) pass def main(): port = int(os.environ.get("GATE_EVALUATOR_PORT", "8080")) server = ThreadingHTTPServer(("0.0.0.0", port), _Handler) server.daemon_threads = True server.serve_forever() if __name__ == "__main__": main()