diff --git a/.claude/references/tool-catalog.md b/.claude/references/tool-catalog.md index 1344d6f6..8ad32b64 100644 --- a/.claude/references/tool-catalog.md +++ b/.claude/references/tool-catalog.md @@ -160,9 +160,10 @@ These are fast first-pass scanners — they surface candidate addresses. Follow ## Dynamic Analysis (`livetools/`) -- Frida-based, attaches to running process ``` -python -m livetools attach # start session -python -m livetools detach # end session -python -m livetools status # check connection +python -m livetools attach # attach to running process by name or PID +python -m livetools attach "C:/Games/game.exe" --spawn # launch + instrument before init code runs +python -m livetools detach # end session +python -m livetools status # check connection ``` | Command | Purpose | diff --git a/.claude/rules/tool-dispatch.md b/.claude/rules/tool-dispatch.md index c3a9c6cb..4f9ea296 100644 --- a/.claude/rules/tool-dispatch.md +++ b/.claude/rules/tool-dispatch.md @@ -29,6 +29,8 @@ Everything else in `retools`. Tell it WHAT you need, not HOW. D3D9-specific ques ## Live tools (main agent, attached process) +- `livetools attach ` — attach to running process +- `livetools attach --spawn` — launch exe suspended, instrument, resume (catches init code) - `livetools trace` / `collect` — hit logging, register reads - `livetools bp` / `watch` / `regs` / `stack` / `bt` — breakpoints + inspection - `livetools mem read/write` / `scan` — memory ops diff --git a/.claude/skills/dynamic-analysis/SKILL.md b/.claude/skills/dynamic-analysis/SKILL.md index ccac54d7..4e87f5e6 100644 --- a/.claude/skills/dynamic-analysis/SKILL.md +++ b/.claude/skills/dynamic-analysis/SKILL.md @@ -15,9 +15,10 @@ All commands: `python -m livetools [args]` ### Session ``` -python -m livetools attach # start daemon, attach Frida -python -m livetools detach # release target, stop daemon -python -m livetools status # check state +python -m livetools attach # attach to running process +python -m livetools attach --spawn # launch + instrument before init code runs +python -m livetools detach # release target, stop daemon +python -m livetools status # check state ``` ### Breakpoints (blocking) diff --git a/.cursor/rules/tool-catalog.mdc b/.cursor/rules/tool-catalog.mdc index cf711944..fad29303 100644 --- a/.cursor/rules/tool-catalog.mdc +++ b/.cursor/rules/tool-catalog.mdc @@ -161,9 +161,10 @@ These are fast first-pass scanners — they surface candidate addresses. Follow ## Dynamic Analysis (`livetools/`) -- Frida-based, attaches to running process ``` -python -m livetools attach # start session -python -m livetools detach # end session -python -m livetools status # check connection +python -m livetools attach # attach to running process by name or PID +python -m livetools attach "C:/Games/game.exe" --spawn # launch + instrument before init code runs +python -m livetools detach # end session +python -m livetools status # check connection ``` | Command | Purpose | diff --git a/.cursor/skills/dynamic-analysis/SKILL.md b/.cursor/skills/dynamic-analysis/SKILL.md index 5a6d96cf..2b185406 100644 --- a/.cursor/skills/dynamic-analysis/SKILL.md +++ b/.cursor/skills/dynamic-analysis/SKILL.md @@ -15,9 +15,10 @@ All commands: `python -m livetools [args]` ### Session ``` -python -m livetools attach # start daemon, attach Frida -python -m livetools detach # release target, stop daemon -python -m livetools status # check state +python -m livetools attach # attach to running process +python -m livetools attach --spawn # launch + instrument before init code runs +python -m livetools detach # release target, stop daemon +python -m livetools status # check state ``` ### Breakpoints (blocking) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c1634480..165cfed1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -30,7 +30,7 @@ Run all tools from the repo root using `python -m ` syntax (e.g. `python ## Live Tools First -The main agent owns `livetools` — always use them to verify static findings and act on leads from subagents. When a subagent returns addresses or candidates, immediately follow up with live tools (trace, breakpoint, mem read/write) rather than spawning more static analysis. Static analysis finds clues; live tools confirm and act on them. Do not wait idle for subagents — use live tools to explore independently while static analysis runs in the background. +The main agent owns `livetools` — always use them to verify static findings and act on leads from subagents. Use `attach ` for running processes, or `attach --spawn` to launch + instrument before init code runs. When a subagent returns addresses or candidates, immediately follow up with live tools (trace, breakpoint, mem read/write) rather than spawning more static analysis. Static analysis finds clues; live tools confirm and act on them. Do not wait idle for subagents — use live tools to explore independently while static analysis runs in the background. ## Dual-Backend Decompilation diff --git a/.gitignore b/.gitignore index 3ec7e597..0ecdde58 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ dxtrace_frame.jsonl # livetools daemon state livetools/.state.json +livetools/.daemon.log # IDE / Editor .vs/ diff --git a/.kiro/powers/dynamic-analysis/POWER.md b/.kiro/powers/dynamic-analysis/POWER.md index 13e513d9..2c9f1c9c 100644 --- a/.kiro/powers/dynamic-analysis/POWER.md +++ b/.kiro/powers/dynamic-analysis/POWER.md @@ -18,9 +18,10 @@ All commands: `python -m livetools [args]` ### Session ``` -python -m livetools attach # start daemon, attach Frida -python -m livetools detach # release target, stop daemon -python -m livetools status # check state +python -m livetools attach # attach to running process +python -m livetools attach --spawn # launch + instrument before init code runs +python -m livetools detach # release target, stop daemon +python -m livetools status # check state ``` ### Breakpoints (blocking) diff --git a/.kiro/steering/tool-catalog.md b/.kiro/steering/tool-catalog.md index 8816f8d3..4fde1403 100644 --- a/.kiro/steering/tool-catalog.md +++ b/.kiro/steering/tool-catalog.md @@ -161,9 +161,10 @@ These are fast first-pass scanners — they surface candidate addresses. Follow ## Dynamic Analysis (`livetools/`) -- Frida-based, attaches to running process ``` -python -m livetools attach # start session -python -m livetools detach # end session -python -m livetools status # check connection +python -m livetools attach # attach to running process by name or PID +python -m livetools attach "C:/Games/game.exe" --spawn # launch + instrument before init code runs +python -m livetools detach # end session +python -m livetools status # check connection ``` | Command | Purpose | diff --git a/livetools/__main__.py b/livetools/__main__.py index 500e310a..0304987d 100644 --- a/livetools/__main__.py +++ b/livetools/__main__.py @@ -55,6 +55,7 @@ import subprocess import sys import time +from pathlib import Path from . import client @@ -74,6 +75,13 @@ def _require_attached() -> bool: # ── session commands ─────────────────────────────────────────────────────── def cmd_attach(args: argparse.Namespace) -> None: + spawn = getattr(args, "spawn", False) + if spawn: + target_path = Path(args.target).resolve() + if not target_path.is_file(): + print(f"[error] --spawn target not found: {target_path}", file=sys.stderr) + sys.exit(1) + if client.is_daemon_alive(): try: resp = client.send_command({"cmd": "status"}) @@ -86,7 +94,7 @@ def cmd_attach(args: argparse.Namespace) -> None: print("Stale daemon detected, cleaning up...") _force_cleanup() - _spawn_daemon(args.target) + _spawn_daemon(args.target, spawn=spawn) def _force_cleanup() -> None: @@ -98,8 +106,10 @@ def _force_cleanup() -> None: time.sleep(0.5) -def _spawn_daemon(target: str) -> None: +def _spawn_daemon(target: str, *, spawn: bool = False) -> None: daemon_cmd = [sys.executable, "-m", "livetools.server", target] + if spawn: + daemon_cmd.append("--spawn") kwargs: dict = {} if sys.platform == "win32": CREATE_NO_WINDOW = 0x08000000 @@ -107,8 +117,10 @@ def _spawn_daemon(target: str) -> None: else: kwargs["start_new_session"] = True kwargs["stdout"] = subprocess.DEVNULL - kwargs["stderr"] = subprocess.DEVNULL + log_fh = client.DAEMON_LOG.open("w") + kwargs["stderr"] = log_fh subprocess.Popen(daemon_cmd, **kwargs) + log_fh.close() deadline = time.time() + 15 while time.time() < deadline: @@ -116,9 +128,19 @@ def _spawn_daemon(target: str) -> None: resp = client.send_command({"cmd": "status"}) print(client.format_status_line(resp)) print(f"Attached to {target}.") + client.DAEMON_LOG.unlink(missing_ok=True) return time.sleep(0.3) + print("[error] Daemon did not start within 15 seconds.", file=sys.stderr) + log_text = "" + try: + log_text = client.DAEMON_LOG.read_text().strip() + except OSError: + pass + if log_text: + print(f"[error] Daemon log:\n{log_text}", file=sys.stderr) + client.DAEMON_LOG.unlink(missing_ok=True) sys.exit(1) @@ -666,11 +688,18 @@ def build_parser() -> argparse.ArgumentParser: help="Attach to a running process (starts background daemon)", description="Attach to a running process by name or PID. " "Starts a background Frida daemon that stays connected.\n\n" + "Use --spawn to launch the executable instead of attaching\n" + "to an already-running process. The process starts suspended,\n" + "Frida instruments it, then resumes -- catching all init code.\n\n" "Example:\n" " python -m livetools attach game.exe\n" - " python -m livetools attach 12345") + " python -m livetools attach 12345\n" + " python -m livetools attach \"C:/Games/game.exe\" --spawn") sp.add_argument("target", - help="Process name (e.g. game.exe) or PID (e.g. 12345)") + help="Process name (e.g. game.exe), PID, or full path with --spawn") + sp.add_argument("--spawn", action="store_true", + help="Launch the executable with Frida (spawn mode) instead of " + "attaching to an already-running process") sub.add_parser("detach", help="Detach from the process and stop the daemon") diff --git a/livetools/client.py b/livetools/client.py index 005ce94d..da91fd93 100644 --- a/livetools/client.py +++ b/livetools/client.py @@ -10,6 +10,7 @@ HOST = "127.0.0.1" PORT = 27042 STATE_FILE = Path(__file__).parent / ".state.json" +DAEMON_LOG = Path(__file__).parent / ".daemon.log" RECV_BUF = 1 << 20 diff --git a/livetools/server.py b/livetools/server.py index ead90b57..c981733e 100644 --- a/livetools/server.py +++ b/livetools/server.py @@ -27,13 +27,16 @@ class Daemon: - def __init__(self, target: str): + def __init__(self, target: str, *, spawn: bool = False): self.target = target + self.spawn_mode = spawn + self.dev: frida.core.Device | None = None self.session: frida.core.Session | None = None self.script: frida.core.Script | None = None self.api = None self.pid: int | None = None self.target_name = target + self._cleaned_up = False self._hit: dict | None = None self._hit_event = threading.Event() @@ -49,32 +52,53 @@ def __init__(self, target: str): # ── Frida setup ──────────────────────────────────────────────────────── def attach(self) -> None: - try: - pid = int(self.target) - except ValueError: - pid = None - - if pid is not None: - self.session = frida.attach(pid) - self.pid = pid + self.dev = frida.get_local_device() + + if self.spawn_mode: + exe_dir = str(Path(self.target).resolve().parent) + self.pid = self.dev.spawn([self.target], cwd=exe_dir) + self.target_name = Path(self.target).name + print(f"[livetools daemon] spawned {self.target_name} (pid {self.pid}), suspended") + self.session = self.dev.attach(self.pid) else: - dev = frida.get_local_device() - for proc in dev.enumerate_processes(): - if proc.name.lower() == self.target.lower(): - self.pid = proc.pid - break - if self.pid is None: - raise RuntimeError(f"Process '{self.target}' not found") - self.session = frida.attach(self.pid) - self.target_name = self.target + try: + pid = int(self.target) + except ValueError: + pid = None + + if pid is not None: + self.session = frida.attach(pid) + self.pid = pid + else: + for proc in self.dev.enumerate_processes(): + if proc.name.lower() == self.target.lower(): + self.pid = proc.pid + break + if self.pid is None: + raise RuntimeError(f"Process '{self.target}' not found") + self.session = frida.attach(self.pid) + self.target_name = self.target self.session.on("detached", self._on_session_detached) - js_code = AGENT_JS.read_text(encoding="utf-8") - self.script = self.session.create_script(js_code) - self.script.on("message", self._on_message) - self.script.load() - self.api = self.script.exports_sync + try: + js_code = AGENT_JS.read_text(encoding="utf-8") + self.script = self.session.create_script(js_code) + self.script.on("message", self._on_message) + self.script.load() + self.api = self.script.exports_sync + except Exception: + if self.spawn_mode: + # Don't leave process suspended forever + try: + self.dev.resume(self.pid) + except Exception: + pass + raise + + if self.spawn_mode: + self.dev.resume(self.pid) + print(f"[livetools daemon] resumed pid {self.pid}") def _on_session_detached(self, reason: str, crash) -> None: print(f"[livetools daemon] target detached: {reason}", file=sys.stderr) @@ -122,9 +146,10 @@ def _on_message(self, message: dict, data) -> None: # ── helpers ──────────────────────────────────────────────────────────── def _base_resp(self) -> dict: - bp_list = self.api.list_bps() if self.api else [] - is_frozen = self.api.is_frozen() if self.api else False - frozen_addr = self.api.get_frozen_addr() if is_frozen else None + api = self.api + bp_list = api.list_bps() if api else [] + is_frozen = api.is_frozen() if api else False + frozen_addr = api.get_frozen_addr() if is_frozen else None return { "target": self.target_name, "pid": self.pid, @@ -700,6 +725,9 @@ def _recv_raw(sock: socket.socket) -> bytes: return b"".join(parts) def _cleanup(self) -> None: + if self._cleaned_up: + return + self._cleaned_up = True try: if self.script: self.script.unload() @@ -719,11 +747,13 @@ def _cleanup(self) -> None: def main() -> None: if len(sys.argv) < 2: - print("Usage: python -m livetools.server ", file=sys.stderr) + print("Usage: python -m livetools.server [--spawn]", + file=sys.stderr) sys.exit(1) target = sys.argv[1] - daemon = Daemon(target) + spawn = "--spawn" in sys.argv[2:] + daemon = Daemon(target, spawn=spawn) def _shutdown(sig, frame): daemon._running = False @@ -731,8 +761,13 @@ def _shutdown(sig, frame): signal.signal(signal.SIGINT, _shutdown) signal.signal(signal.SIGTERM, _shutdown) - daemon.attach() - daemon.serve() + try: + daemon.attach() + daemon.serve() + except Exception as exc: + print(f"[livetools daemon] fatal: {exc}", file=sys.stderr) + finally: + daemon._cleanup() if __name__ == "__main__":