trading-system-docs/red-queen-architecture/control-plane/cp2a1/test_gate_evaluator.py

353 lines
16 KiB
Python

#!/usr/bin/env python3
"""
CP2A1 — Trusted Gate Evaluator Unit-Tests (A-O) + Bypass-Tests (17).
Verwendet Fixture-State (isolierte Test-Verzeichnisse), NICHT den produktiven
Control-State. Kein produktiver State wird veraendert.
"""
import json
import os
import shutil
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gate_evaluator as ge # noqa: E402
def _write_state(state_dir, name, content):
os.makedirs(state_dir, exist_ok=True)
with open(os.path.join(state_dir, name), "w") as f:
f.write(content)
def _make_fixture(global_autonomy="OFF", emergency="OFF", boot_id="boot-1234"):
"""Baut ein isoliertes Fixture-State-Verzeichnis. Rueckgabe: (state_dir, boot_id)."""
tmp = tempfile.mkdtemp(prefix="cp2a1_fixture_")
_write_state(tmp, "global_autonomy", global_autonomy)
_write_state(tmp, "productive_mutations", "OFF")
_write_state(tmp, "save_execution", "OFF")
_write_state(tmp, "delete_execution", "OFF")
_write_state(tmp, "emergency_stop", emergency)
return tmp, boot_id
class GateEvaluatorTest(unittest.TestCase):
def setUp(self):
# Fixture-State isoliert setzen (NICHT produktiv)
self._orig_state_dir = ge.AUTHORITATIVE_STATE_DIR
self._orig_boot_file = ge.AUTHORITATIVE_BOOT_ID_FILE
self._orig_audit_dir = ge.AUDIT_DIR
self._fixture_dir, self._boot_id = _make_fixture()
ge.AUTHORITATIVE_STATE_DIR = self._fixture_dir
ge.AUTHORITATIVE_BOOT_ID_FILE = os.path.join(self._fixture_dir, "boot_id")
ge.AUDIT_DIR = os.path.join(self._fixture_dir, "audit")
# control_reader auf Fixture zeigen lassen
ge.control_reader.STATE_DIR = self._fixture_dir
ge.control_reader.BOOT_ID_FILE = ge.AUTHORITATIVE_BOOT_ID_FILE
# boot_id-Datei schreiben
with open(ge.AUTHORITATIVE_BOOT_ID_FILE, "w") as f:
f.write(self._boot_id)
def tearDown(self):
ge.AUTHORITATIVE_STATE_DIR = self._orig_state_dir
ge.AUTHORITATIVE_BOOT_ID_FILE = self._orig_boot_file
ge.AUDIT_DIR = self._orig_audit_dir
ge.control_reader.STATE_DIR = self._orig_state_dir
ge.control_reader.BOOT_ID_FILE = self._orig_boot_file
shutil.rmtree(self._fixture_dir, ignore_errors=True)
# --- A: global ON + emergency OFF + valid boot -> ALLOW ---
def test_A_global_on_emergency_off_valid_boot_allow(self):
_write_state(self._fixture_dir, "global_autonomy", "ON\nboot_id=boot-1234\n")
_write_state(self._fixture_dir, "emergency_stop", "OFF")
d = ge._check_action("AUTONOMOUS_TICK_START")
self.assertEqual(d["result"], "ALLOW")
self.assertEqual(d["reason_code"], ge.RC_ALLOW)
# --- B: global OFF -> DENY ---
def test_B_global_off_deny(self):
_write_state(self._fixture_dir, "global_autonomy", "OFF")
_write_state(self._fixture_dir, "emergency_stop", "OFF")
d = ge._check_action("AUTONOMOUS_TICK_START")
self.assertEqual(d["result"], "DENY")
self.assertEqual(d["reason_code"], ge.RC_GLOBAL_AUTONOMY_OFF)
# --- C: emergency ON -> DENY ---
def test_C_emergency_on_deny(self):
_write_state(self._fixture_dir, "global_autonomy", "ON\nboot_id=boot-1234\n")
_write_state(self._fixture_dir, "emergency_stop", "ON")
d = ge._check_action("AUTONOMOUS_TICK_START")
self.assertEqual(d["result"], "DENY")
self.assertEqual(d["reason_code"], ge.RC_EMERGENCY_ON)
# --- D: grant boot mismatch -> DENY ---
def test_D_grant_boot_mismatch_deny(self):
_write_state(self._fixture_dir, "global_autonomy", "ON\nboot_id=other-boot\n")
_write_state(self._fixture_dir, "emergency_stop", "OFF")
d = ge._check_action("AUTONOMOUS_TICK_START")
self.assertEqual(d["result"], "DENY")
self.assertEqual(d["reason_code"], ge.RC_GLOBAL_AUTONOMY_OFF)
# --- E: missing global -> DENY ---
def test_E_missing_global_deny(self):
os.remove(os.path.join(self._fixture_dir, "global_autonomy"))
_write_state(self._fixture_dir, "emergency_stop", "OFF")
d = ge._check_action("AUTONOMOUS_TICK_START")
self.assertEqual(d["result"], "DENY")
self.assertEqual(d["reason_code"], ge.RC_GLOBAL_AUTONOMY_OFF)
# --- F: malformed global -> DENY ---
def test_F_malformed_global_deny(self):
_write_state(self._fixture_dir, "global_autonomy", "MAYBE")
_write_state(self._fixture_dir, "emergency_stop", "OFF")
d = ge._check_action("AUTONOMOUS_TICK_START")
self.assertEqual(d["result"], "DENY")
self.assertEqual(d["reason_code"], ge.RC_GLOBAL_AUTONOMY_OFF)
# --- G: missing emergency -> DENY (fail-closed: unklar = ON) ---
def test_G_missing_emergency_deny(self):
_write_state(self._fixture_dir, "global_autonomy", "ON\nboot_id=boot-1234\n")
os.remove(os.path.join(self._fixture_dir, "emergency_stop"))
d = ge._check_action("AUTONOMOUS_TICK_START")
self.assertEqual(d["result"], "DENY")
self.assertEqual(d["reason_code"], ge.RC_EMERGENCY_ON)
# --- H: malformed emergency -> DENY ---
def test_H_malformed_emergency_deny(self):
_write_state(self._fixture_dir, "global_autonomy", "ON\nboot_id=boot-1234\n")
_write_state(self._fixture_dir, "emergency_stop", "MAYBE")
d = ge._check_action("AUTONOMOUS_TICK_START")
self.assertEqual(d["result"], "DENY")
self.assertEqual(d["reason_code"], ge.RC_EMERGENCY_ON)
# --- I: missing boot_id -> DENY ---
def test_I_missing_boot_id_deny(self):
_write_state(self._fixture_dir, "global_autonomy", "ON\nboot_id=boot-1234\n")
_write_state(self._fixture_dir, "emergency_stop", "OFF")
os.remove(ge.AUTHORITATIVE_BOOT_ID_FILE)
d = ge._check_action("AUTONOMOUS_TICK_START")
self.assertEqual(d["result"], "DENY")
self.assertEqual(d["reason_code"], ge.RC_BOOT_ID_ERROR)
# --- J: malformed boot_id -> DENY ---
def test_J_malformed_boot_id_deny(self):
_write_state(self._fixture_dir, "global_autonomy", "ON\nboot_id=boot-1234\n")
_write_state(self._fixture_dir, "emergency_stop", "OFF")
with open(ge.AUTHORITATIVE_BOOT_ID_FILE, "w") as f:
f.write("has whitespace\n")
d = ge._check_action("AUTONOMOUS_TICK_START")
self.assertEqual(d["result"], "DENY")
self.assertEqual(d["reason_code"], ge.RC_BOOT_ID_ERROR)
# --- K: unknown action -> DENY ---
def test_K_unknown_action_deny(self):
_write_state(self._fixture_dir, "global_autonomy", "ON\nboot_id=boot-1234\n")
_write_state(self._fixture_dir, "emergency_stop", "OFF")
for cls in ("SAVE", "DELETE", "FORGEJO_WRITE", "NOTION_WRITE", "TELEGRAM_SEND",
"HOST_MUTATION", "EXTERNAL_MUTATION", "BOGUS"):
d = ge._check_action(cls)
self.assertEqual(d["result"], "DENY", f"action {cls} should DENY")
self.assertEqual(d["reason_code"], ge.RC_UNKNOWN_ACTION_CLASS)
# --- L: malformed request -> DENY ---
def test_L_malformed_request_deny(self):
cases = [
b"",
b"not json",
b"[]",
b'"string"',
b"null",
b"{}",
b'{"action_class": 123}',
b'{"action_class": ""}',
b'{"action_class": "AUTONOMOUS_TICK_START", "extra": 1}',
]
for body in cases:
cls, err = ge._validate_check_request(body)
self.assertIsNone(cls, f"body {body!r} should be invalid")
self.assertEqual(err, ge.RC_INVALID_REQUEST)
# --- M: evaluator exception -> DENY ---
def test_M_evaluator_exception_deny(self):
_write_state(self._fixture_dir, "global_autonomy", "ON\nboot_id=boot-1234\n")
_write_state(self._fixture_dir, "emergency_stop", "OFF")
# Simuliere Exception in read_control_state -> _effective_state -> None -> DENY
orig = ge.control_reader.read_control_state
ge.control_reader.read_control_state = lambda: (_ for _ in ()).throw(RuntimeError("boom"))
try:
d = ge._check_action("AUTONOMOUS_TICK_START")
finally:
ge.control_reader.read_control_state = orig
self.assertEqual(d["result"], "DENY")
self.assertEqual(d["reason_code"], ge.RC_INTERNAL_ERROR)
# --- N: cache/stale-state cannot authorize ---
def test_N_no_cache_stale_state(self):
# Erst ALLOW, dann State auf OFF aendern -> naechste Entscheidung muss DENY sein
_write_state(self._fixture_dir, "global_autonomy", "ON\nboot_id=boot-1234\n")
_write_state(self._fixture_dir, "emergency_stop", "OFF")
d1 = ge._check_action("AUTONOMOUS_TICK_START")
self.assertEqual(d1["result"], "ALLOW")
# State aendern (kein Cache)
_write_state(self._fixture_dir, "global_autonomy", "OFF")
d2 = ge._check_action("AUTONOMOUS_TICK_START")
self.assertEqual(d2["result"], "DENY")
self.assertEqual(d2["reason_code"], ge.RC_GLOBAL_AUTONOMY_OFF)
# --- O: RQ-supplied path ignored/rejected ---
def test_O_rq_supplied_path_ignored(self):
# Der Evaluator akzeptiert KEINEN State-Pfad im Request.
# /check-Request mit "state_path" Feld -> INVALID_REQUEST
cls, err = ge._validate_check_request(
b'{"action_class": "AUTONOMOUS_TICK_START", "state_path": "/etc/passwd"}'
)
self.assertIsNone(cls)
self.assertEqual(err, ge.RC_INVALID_REQUEST)
class BypassTest(unittest.TestCase):
"""Fresh isolated checker: versucht Bypass-Pfade. Erwartung: kein Bypass."""
def setUp(self):
self._orig_state_dir = ge.AUTHORITATIVE_STATE_DIR
self._orig_boot_file = ge.AUTHORITATIVE_BOOT_ID_FILE
self._orig_audit_dir = ge.AUDIT_DIR
self._fixture_dir, self._boot_id = _make_fixture()
ge.AUTHORITATIVE_STATE_DIR = self._fixture_dir
ge.AUTHORITATIVE_BOOT_ID_FILE = os.path.join(self._fixture_dir, "boot_id")
ge.AUDIT_DIR = os.path.join(self._fixture_dir, "audit")
ge.control_reader.STATE_DIR = self._fixture_dir
ge.control_reader.BOOT_ID_FILE = ge.AUTHORITATIVE_BOOT_ID_FILE
with open(ge.AUTHORITATIVE_BOOT_ID_FILE, "w") as f:
f.write(self._boot_id)
def tearDown(self):
ge.AUTHORITATIVE_STATE_DIR = self._orig_state_dir
ge.AUTHORITATIVE_BOOT_ID_FILE = self._orig_boot_file
ge.AUDIT_DIR = self._orig_audit_dir
ge.control_reader.STATE_DIR = self._orig_state_dir
ge.control_reader.BOOT_ID_FILE = self._orig_boot_file
shutil.rmtree(self._fixture_dir, ignore_errors=True)
def _allow_setup(self):
_write_state(self._fixture_dir, "global_autonomy", "ON\nboot_id=boot-1234\n")
_write_state(self._fixture_dir, "emergency_stop", "OFF")
def test_alternate_state_path(self):
# Request mit state_path -> INVALID_REQUEST (kein Bypass)
cls, err = ge._validate_check_request(
b'{"action_class": "AUTONOMOUS_TICK_START", "state_path": "/tmp/evil"}'
)
self.assertIsNone(cls)
self.assertEqual(err, ge.RC_INVALID_REQUEST)
def test_path_traversal(self):
# Path-Traversal-Versuch in action_class -> nicht in Whitelist -> DENY
cls, err = ge._validate_check_request(
b'{"action_class": "../../etc/passwd"}'
)
self.assertEqual(cls, "../../etc/passwd")
d = ge._check_action(cls)
self.assertEqual(d["result"], "DENY")
self.assertEqual(d["reason_code"], ge.RC_UNKNOWN_ACTION_CLASS)
def test_env_override(self):
# ENV kann die hart verdrahteten Pfade NICHT ueberschreiben.
# Die Default-Konstanten (vor Fixture-Ueberschreibung) sind die autoritativen Pfade.
self.assertEqual(ge._DEFAULT_STATE_DIR, "/opt/control-plane/state")
self.assertEqual(ge._DEFAULT_BOOT_ID_FILE, "/proc/sys/kernel/random/boot_id")
# ENV-Variablen werden im Evaluator nicht gelesen (kein os.environ.get fuer Pfade)
self.assertNotIn("C5_CONTROL_STATE_DIR", os.environ)
self.assertNotIn("C5_BOOT_ID_FILE", os.environ)
def test_action_injection(self):
# Action Class in anderem Feld -> INVALID_REQUEST
cls, err = ge._validate_check_request(
b'{"action_class": "AUTONOMOUS_TICK_START", "inject": "SAVE"}'
)
self.assertIsNone(cls)
self.assertEqual(err, ge.RC_INVALID_REQUEST)
def test_oversized_request(self):
big = b'{"action_class": "' + b"A" * 5000 + b'"}'
cls, err = ge._validate_check_request(big)
self.assertIsNone(cls)
self.assertEqual(err, ge.RC_INVALID_REQUEST)
def test_duplicate_fields(self):
# JSON mit doppeltem action_class: json.loads loest still auf (letzter Wert).
# Der Wert wird trotzdem gegen die Whitelist validiert -> kein Bypass.
cls, err = ge._validate_check_request(
b'{"action_class": "AUTONOMOUS_TICK_START", "action_class": "AUTONOMOUS_TICK_START"}'
)
self.assertEqual(cls, "AUTONOMOUS_TICK_START")
# Duplikat mit bösartigem Wert -> DENY (Whitelist-Check greift)
cls2, _ = ge._validate_check_request(
b'{"action_class": "AUTONOMOUS_TICK_START", "action_class": "SAVE"}'
)
self.assertEqual(cls2, "SAVE")
d = ge._check_action(cls2)
self.assertEqual(d["result"], "DENY")
self.assertEqual(d["reason_code"], ge.RC_UNKNOWN_ACTION_CLASS)
def test_null_values(self):
cls, err = ge._validate_check_request(b'{"action_class": null}')
self.assertIsNone(cls)
self.assertEqual(err, ge.RC_INVALID_REQUEST)
def test_unicode_confusable_action(self):
# Unicode-Konfusables sind nicht in der Whitelist -> DENY
cls, err = ge._validate_check_request(b'{"action_class": "AUTONOMOUS_TICK_START\\u200b"}')
# Zero-Width-Space -> nicht in Whitelist -> UNKNOWN_ACTION_CLASS
self.assertEqual(cls, "AUTONOMOUS_TICK_START\u200b")
d = ge._check_action(cls)
self.assertEqual(d["result"], "DENY")
self.assertEqual(d["reason_code"], ge.RC_UNKNOWN_ACTION_CLASS)
def test_unknown_json_keys(self):
cls, err = ge._validate_check_request(b'{"foo": "bar"}')
self.assertIsNone(cls)
self.assertEqual(err, ge.RC_INVALID_REQUEST)
def test_malformed_json(self):
cls, err = ge._validate_check_request(b'{"action_class": "AUTONOMOUS_TICK_START"')
self.assertIsNone(cls)
self.assertEqual(err, ge.RC_INVALID_REQUEST)
def test_replay_same_request(self):
# Replay desselben Requests: Entscheidung wird frisch berechnet (kein Cache).
# Bei State=OFF -> DENY, auch wenn vorher ALLOW war.
self._allow_setup()
d1 = ge._check_action("AUTONOMOUS_TICK_START")
self.assertEqual(d1["result"], "ALLOW")
_write_state(self._fixture_dir, "global_autonomy", "OFF")
d2 = ge._check_action("AUTONOMOUS_TICK_START")
self.assertEqual(d2["result"], "DENY")
def test_evaluator_restart(self):
# Simuliere Restart: State bleibt, Entscheidung frisch
self._allow_setup()
d1 = ge._check_action("AUTONOMOUS_TICK_START")
self.assertEqual(d1["result"], "ALLOW")
# "Restart" = neue Instanz (frischer Aufruf)
d2 = ge._check_action("AUTONOMOUS_TICK_START")
self.assertEqual(d2["result"], "ALLOW")
def test_host_boot_id_fixture_change(self):
# boot_id aendern -> Grant boot mismatch -> DENY
self._allow_setup()
d1 = ge._check_action("AUTONOMOUS_TICK_START")
self.assertEqual(d1["result"], "ALLOW")
with open(ge.AUTHORITATIVE_BOOT_ID_FILE, "w") as f:
f.write("new-boot-9999")
d2 = ge._check_action("AUTONOMOUS_TICK_START")
self.assertEqual(d2["result"], "DENY")
self.assertEqual(d2["reason_code"], ge.RC_GLOBAL_AUTONOMY_OFF)
if __name__ == "__main__":
unittest.main()