Per-agent x64dbg session through debugger()/debugger_tools()

Harbor starts the x64dbg-automate MCP server (stdio) on first use and keeps
it for the life of the agent's Harbor process; the debuggee is terminated
when that process exits. Configured by debugger_command/_path/_temp in
[reverse].
This commit is contained in:
KeelrLeek committed 2026-10-05 12:44:25 -05:00
1 parent 7ed914ebdb
commit 584fabeb74
5 files changed
+175 -4

No files matched your search

+8 -3
View File
@@ -255,7 +255,7 @@ def build_parser():
p = sub.add_parser("who", help="who has worked on a scope (notes, reviews, claims, messages)") p = sub.add_parser("who", help="who has worked on a scope (notes, reviews, claims, messages)")
p.add_argument("scope") p.add_argument("scope")
p = sub.add_parser("re", help="reverse-engineering lookups from the project's image and annotations") p = sub.add_parser("re", help="reverse-engineering lookups from the project's image and annotations")
p.add_argument("action", choices=["disasm", "xrefs", "bytes", "lookup", "ghidra", "ghidra-tools"]) p.add_argument("action", choices=["disasm", "xrefs", "bytes", "lookup", "ghidra", "ghidra-tools", "debugger-tools"])
p.add_argument("target", nargs="?", default="", help="an address (0x4AD850) or a name; for ghidra, the tool") p.add_argument("target", nargs="?", default="", help="an address (0x4AD850) or a name; for ghidra, the tool")
p.add_argument("arguments", nargs="?", default="{}", help="ghidra: the tool's arguments as JSON") p.add_argument("arguments", nargs="?", default="{}", help="ghidra: the tool's arguments as JSON")
p.add_argument("--max-bytes", type=int, dest="max_bytes") p.add_argument("--max-bytes", type=int, dest="max_bytes")
@@ -675,8 +675,13 @@ def main(argv=None):
out(r, f"summary #{r['id']} pinned to {r['thread']}") out(r, f"summary #{r['id']} pinned to {r['thread']}")
elif cmd == "re": elif cmd == "re":
from .reverse import Ghidra, Reverse from .reverse import Ghidra, Reverse
r = None if args.action.startswith("ghidra") else Reverse(harbor.project.reverse) r = None if args.action.startswith(("ghidra", "debugger")) else Reverse(harbor.project.reverse)
if args.action == "ghidra-tools": if args.action == "debugger-tools":
from .reverse import Debugger
res = Debugger(harbor.project.reverse).tools()
out(res, "\n".join(f"{t['name']:<22} {(t['description'] or '').splitlines()[0][:90] if t['description'] else ''}"
for t in res))
elif args.action == "ghidra-tools":
res = Ghidra(harbor.project.reverse).tools() res = Ghidra(harbor.project.reverse).tools()
out(res, "\n".join(f"{t['name']:<26} {t['description'].splitlines()[0][:90]}" for t in res)) out(res, "\n".join(f"{t['name']:<26} {t['description'].splitlines()[0][:90]}" for t in res))
elif args.action == "ghidra": elif args.action == "ghidra":
+34 -1
View File
@@ -37,7 +37,9 @@ review queue, presence and stop flags. Protocol:
11. To read the original machine code use re_disassemble, re_xrefs, re_bytes and re_lookup: they read the project's 11. To read the original machine code use re_disassemble, re_xrefs, re_bytes and re_lookup: they read the project's
own image with the team's current names, so do not write your own disassembler or offset maths. own image with the team's current names, so do not write your own disassembler or offset maths.
For Ghidra's view (decompiler, p-code, stack frames, struct field references, types) use ghidra_tools() once, For Ghidra's view (decompiler, p-code, stack frames, struct field references, types) use ghidra_tools() once,
then ghidra(tool, arguments): one shared read-only Ghidra session holding the team's annotations.""" then ghidra(tool, arguments): one shared read-only Ghidra session holding the team's annotations.
To check a value in the running game (x87 control word, `this`, struct fields), use debugger_tools() and
debugger(tool, arguments): your own x32dbg session; always end it with debugger('terminate_session')."""
KindName = Literal["note", "question", "answer", "request", "finding", "retraction", "decision", "status", "handoff"] KindName = Literal["note", "question", "answer", "request", "finding", "retraction", "decision", "status", "handoff"]
Importance = Literal["normal", "high", "urgent"] Importance = Literal["normal", "high", "urgent"]
@@ -517,6 +519,37 @@ def ghidra(tool: str, arguments: dict | None = None) -> str:
return _gh(lambda g: g.call(tool, arguments or {})) return _gh(lambda g: g.call(tool, arguments or {}))
_debugger = {}
def _dbg(fn):
try:
h = harbor()
d = _debugger.get(h.project.name)
if d is None:
from .reverse import Debugger
d = _debugger[h.project.name] = Debugger(h.project.reverse)
return fn(d)
except (HarborError, projects.ProjectError) as error:
raise ToolError(str(error)) from None
@mcp.tool()
def debugger_tools() -> list:
"""The tools of your own x64dbg session (x64dbg-automate on x32dbg): start_session, set_breakpoint, go,
get_all_registers, read_memory, eval_expression, ... Call them with debugger(tool, arguments)."""
return _dbg(lambda d: d.tools())
@mcp.tool()
def debugger(tool: str, arguments: dict | None = None) -> str:
"""Run one x64dbg tool in your own debugger session, e.g. debugger('start_session', {'target_exe':
"R:/SteamLibrary/steamapps/common/Sid Meier's Pirates!/Pirates!.exe"}), debugger('set_breakpoint', ...),
debugger('go'), debugger('get_all_registers'). The session lasts until debugger('terminate_session') or
until your app closes. Use it to read values, not to patch the game."""
return _dbg(lambda d: d.call(tool, arguments or {}))
def _report_in(): def _report_in():
"""Tell Harbor this agent's app has its tools running: at start, then every 5 minutes while the app keeps the """Tell Harbor this agent's app has its tools running: at start, then every 5 minutes while the app keeps the
server open (updates the connection only, not the agent's last-seen time).""" server open (updates the connection only, not the agent's last-seen time)."""
+96
View File
@@ -356,3 +356,99 @@ class Ghidra:
if result.get("isError"): if result.get("isError"):
raise HarborError(text.strip() or f"Ghidra refused {tool}") raise HarborError(text.strip() or f"Ghidra refused {tool}")
return re.sub(r"^\[Context\] Operating on: [^\n]*\n+", "", text) return re.sub(r"^\[Context\] Operating on: [^\n]*\n+", "", text)
class Debugger:
"""A per-agent x64dbg session through the x64dbg-automate MCP server (stdio), started on first use and kept for
the life of this Harbor process, so breakpoints and the paused game survive between calls. Configured by
`debugger_command`, `debugger_path` (x32dbg.exe) and `debugger_temp` in the [reverse] table. The plugin and the
client find each other through a session file in TEMP, so both get the same TEMP."""
def __init__(self, config):
config = config or {}
self.command = config.get("debugger_command")
if not self.command:
raise HarborError("this project has no debugger_command in its [reverse] table")
self.env = dict(os.environ)
if config.get("debugger_path"):
self.env["X64DBG_PATH"] = str(config["debugger_path"])
if config.get("debugger_temp"):
self.env["TEMP"] = self.env["TMP"] = str(config["debugger_temp"])
self.proc = None
self.counter = 0
self.lock = __import__("threading").Lock()
def _start(self):
import atexit
import queue
import subprocess
import threading
command = self.command if isinstance(self.command, list) else [self.command]
self.proc = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, env=self.env, text=True, encoding="utf-8",
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
self.answers = queue.Queue()
def read(stream, answers):
for line in stream:
if line.strip():
try:
answers.put(json.loads(line))
except ValueError:
pass
answers.put(None)
threading.Thread(target=read, args=(self.proc.stdout, self.answers), daemon=True).start()
self._rpc("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "harbor", "version": "2"}})
self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
atexit.register(self.close)
def _send(self, body):
self.proc.stdin.write(json.dumps(body) + "\n")
self.proc.stdin.flush()
def _rpc(self, method, params, timeout=120):
import queue
self.counter += 1
self._send({"jsonrpc": "2.0", "id": self.counter, "method": method, "params": params})
while True:
try:
answer = self.answers.get(timeout=timeout)
except queue.Empty:
raise HarborError(f"the debugger did not answer {method} within {timeout}s") from None
if answer is None:
self.proc = None
raise HarborError("the debugger's MCP server exited")
if answer.get("id") == self.counter:
if "error" in answer:
raise HarborError(f"debugger: {answer['error'].get('message', answer['error'])}")
return answer.get("result")
def tools(self):
with self.lock:
if self.proc is None or self.proc.poll() is not None:
self._start()
return [{"name": t["name"], "description": t.get("description", ""),
"arguments": (t.get("inputSchema") or {}).get("properties", {}),
"required": (t.get("inputSchema") or {}).get("required", [])}
for t in self._rpc("tools/list", {})["tools"]]
def call(self, tool, arguments=None, timeout=300):
with self.lock:
if self.proc is None or self.proc.poll() is not None:
self._start()
result = self._rpc("tools/call", {"name": tool, "arguments": arguments or {}}, timeout=timeout)
text = "\n".join(c.get("text", "") for c in result.get("content", []) if isinstance(c, dict))
if result.get("isError"):
raise HarborError(text.strip() or f"the debugger refused {tool}")
return text
def close(self):
"""End the debuggee and the debugger with the Harbor process, so no paused game is left behind."""
if self.proc and self.proc.poll() is None:
try:
self._rpc("tools/call", {"name": "terminate_session", "arguments": {}}, timeout=15)
except HarborError:
pass
self.proc.terminate()
self.proc = None
+18
View File
@@ -0,0 +1,18 @@
"""A fake stdio MCP server for the debugger forwarder test: one tool that echoes, one that fails."""
import json
import sys
for line in sys.stdin:
msg = json.loads(line)
if "id" not in msg:
continue
if msg["method"] == "initialize":
result = {"protocolVersion": "2025-06-18"}
elif msg["method"] == "tools/list":
result = {"tools": [{"name": "echo", "description": "Echo", "inputSchema": {"properties": {"x": {}}}}]}
elif msg["params"]["name"] == "echo":
result = {"content": [{"type": "text", "text": "echo " + json.dumps(msg["params"]["arguments"])}]}
else:
result = {"isError": True, "content": [{"type": "text", "text": "no such session"}]}
print(json.dumps({"jsonrpc": "2.0", "method": "notifications/message", "params": {}}), flush=True)
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": result}), flush=True)
+19
View File
@@ -117,3 +117,22 @@ class GhidraForwarder(unittest.TestCase):
with self.assertRaises(HarborError): with self.assertRaises(HarborError):
from harbor.reverse import Ghidra from harbor.reverse import Ghidra
Ghidra({"ghidra_url": "http://127.0.0.1:9/mcp"}).tools() Ghidra({"ghidra_url": "http://127.0.0.1:9/mcp"}).tools()
class DebuggerForwarder(unittest.TestCase):
"""debugger() keeps one stdio MCP server per Harbor process and skips its notifications."""
def test_call_and_error(self):
import sys
from pathlib import Path
from harbor.reverse import Debugger
d = Debugger({"debugger_command": [sys.executable, "-B", str(Path(__file__).with_name("fake_stdio_mcp.py"))]})
try:
self.assertEqual(d.tools()[0]["name"], "echo")
self.assertEqual(d.call("echo", {"x": 1}), 'echo {"x": 1}')
with self.assertRaises(HarborError):
d.call("terminate_session")
finally:
d.close()
with self.assertRaises(HarborError):
Debugger({})