From 4a218cdb0506f2d1fddf4e3641cbbcced7ee802b Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 4 Jul 2026 06:57:59 -0700 Subject: [PATCH] feat: add condition evaluator for runtime-observation MCP protocol --- tests/test_havoc_debug_protocol.py | 43 ++++++++++++++++++++++ tools/havoc_debug_protocol.py | 59 ++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 tests/test_havoc_debug_protocol.py create mode 100644 tools/havoc_debug_protocol.py diff --git a/tests/test_havoc_debug_protocol.py b/tests/test_havoc_debug_protocol.py new file mode 100644 index 0000000..4410ef7 --- /dev/null +++ b/tests/test_havoc_debug_protocol.py @@ -0,0 +1,43 @@ +import pytest +from tools.havoc_debug_protocol import evaluate_condition + +def test_register_condition_true(): + registers = {"eax": 0x50} + condition = {"register": "eax", "op": "!=", "value": "0x47"} + assert evaluate_condition(condition, registers, read_memory=None) is True + +def test_register_condition_false(): + registers = {"eax": 0x47} + condition = {"register": "eax", "op": "!=", "value": "0x47"} + assert evaluate_condition(condition, registers, read_memory=None) is False + +def test_register_condition_equals(): + registers = {"eip": 0x436070} + condition = {"register": "eip", "op": "==", "value": "0x436070"} + assert evaluate_condition(condition, registers, read_memory=None) is True + +def test_memory_condition_with_offset(): + def fake_read_memory(addr, size): + assert addr == 0x47eb48 + 0x18 + assert size == 4 + return (0x47).to_bytes(4, "little") + condition = {"memory": "0x47eb48+0x18", "size": 4, "op": "==", "value": "0x47"} + assert evaluate_condition(condition, {}, fake_read_memory) is True + +def test_memory_condition_no_offset(): + def fake_read_memory(addr, size): + assert addr == 0x47da88 + return (0).to_bytes(size, "little") + condition = {"memory": "0x47da88", "size": 4, "op": "==", "value": "0x0"} + assert evaluate_condition(condition, {}, fake_read_memory) is True + +def test_comparison_operators(): + registers = {"ecx": 10} + assert evaluate_condition({"register": "ecx", "op": "<", "value": "0xb"}, registers, None) + assert evaluate_condition({"register": "ecx", "op": ">", "value": "0x9"}, registers, None) + assert evaluate_condition({"register": "ecx", "op": "<=", "value": "0xa"}, registers, None) + assert evaluate_condition({"register": "ecx", "op": ">=", "value": "0xa"}, registers, None) + +def test_unknown_register_raises(): + with pytest.raises(KeyError): + evaluate_condition({"register": "bogus", "op": "==", "value": "0x0"}, {}, None) diff --git a/tools/havoc_debug_protocol.py b/tools/havoc_debug_protocol.py new file mode 100644 index 0000000..c0fa025 --- /dev/null +++ b/tools/havoc_debug_protocol.py @@ -0,0 +1,59 @@ +"""Wire protocol and pure-logic helpers shared between the debug agent and MCP server. +No ctypes, no Windows dependency -- runs and tests on any platform.""" + +from __future__ import annotations + +import json +import operator +from typing import Callable, Optional + +_OPS = { + "==": operator.eq, + "!=": operator.ne, + "<": operator.lt, + ">": operator.gt, + "<=": operator.le, + ">=": operator.ge, +} + + +def _parse_memory_expr(expr: str) -> int: + """Parse "0x47eb48+0x18" or "0x47eb48" into an absolute address.""" + if "+" in expr: + base_str, offset_str = expr.split("+", 1) + return int(base_str.strip(), 16) + int(offset_str.strip(), 16) + return int(expr.strip(), 16) + + +def evaluate_condition( + condition: dict, + registers: dict, + read_memory: Optional[Callable[[int, int], bytes]], +) -> bool: + """Evaluate a conditional-breakpoint expression against a register snapshot and/or + memory reader. `condition` is either {"register": name, "op": ..., "value": hexstr} + or {"memory": expr, "size": n, "op": ..., "value": hexstr}.""" + op_fn = _OPS[condition["op"]] + expected = int(condition["value"], 16) + + if "register" in condition: + actual = registers[condition["register"]] + elif "memory" in condition: + addr = _parse_memory_expr(condition["memory"]) + size = condition["size"] + raw = read_memory(addr, size) + actual = int.from_bytes(raw, "little") + else: + raise ValueError(f"condition must have 'register' or 'memory': {condition}") + + return op_fn(actual, expected) + + +def encode_message(op: str, params: dict) -> bytes: + """Encode a request as a single NDJSON line.""" + return (json.dumps({"op": op, "params": params}) + "\n").encode("utf-8") + + +def decode_message(line: bytes) -> dict: + """Decode a single NDJSON line (request or response) back into a dict.""" + return json.loads(line.decode("utf-8"))