#!/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()