Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .claude/references/tool-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <process> # start session
python -m livetools detach # end session
python -m livetools status # check connection
python -m livetools attach <process> # 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 |
Expand Down
2 changes: 2 additions & 0 deletions .claude/rules/tool-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name_or_pid>` — attach to running process
- `livetools attach <path> --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
Expand Down
7 changes: 4 additions & 3 deletions .claude/skills/dynamic-analysis/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ All commands: `python -m livetools <command> [args]`

### Session
```
python -m livetools attach <name_or_pid> # start daemon, attach Frida
python -m livetools detach # release target, stop daemon
python -m livetools status # check state
python -m livetools attach <name_or_pid> # attach to running process
python -m livetools attach <exe_path> --spawn # launch + instrument before init code runs
python -m livetools detach # release target, stop daemon
python -m livetools status # check state
```

### Breakpoints (blocking)
Expand Down
7 changes: 4 additions & 3 deletions .cursor/rules/tool-catalog.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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 <process> # start session
python -m livetools detach # end session
python -m livetools status # check connection
python -m livetools attach <process> # 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 |
Expand Down
7 changes: 4 additions & 3 deletions .cursor/skills/dynamic-analysis/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ All commands: `python -m livetools <command> [args]`

### Session
```
python -m livetools attach <name_or_pid> # start daemon, attach Frida
python -m livetools detach # release target, stop daemon
python -m livetools status # check state
python -m livetools attach <name_or_pid> # attach to running process
python -m livetools attach <exe_path> --spawn # launch + instrument before init code runs
python -m livetools detach # release target, stop daemon
python -m livetools status # check state
```

### Breakpoints (blocking)
Expand Down
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Run all tools from the repo root using `python -m <module>` 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 <name_or_pid>` for running processes, or `attach <path> --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

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ dxtrace_frame.jsonl

# livetools daemon state
livetools/.state.json
livetools/.daemon.log

# IDE / Editor
.vs/
Expand Down
7 changes: 4 additions & 3 deletions .kiro/powers/dynamic-analysis/POWER.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ All commands: `python -m livetools <command> [args]`

### Session
```
python -m livetools attach <name_or_pid> # start daemon, attach Frida
python -m livetools detach # release target, stop daemon
python -m livetools status # check state
python -m livetools attach <name_or_pid> # attach to running process
python -m livetools attach <exe_path> --spawn # launch + instrument before init code runs
python -m livetools detach # release target, stop daemon
python -m livetools status # check state
```

### Breakpoints (blocking)
Expand Down
7 changes: 4 additions & 3 deletions .kiro/steering/tool-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <process> # start session
python -m livetools detach # end session
python -m livetools status # check connection
python -m livetools attach <process> # 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 |
Expand Down
39 changes: 34 additions & 5 deletions livetools/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
import subprocess
import sys
import time
from pathlib import Path

from . import client

Expand All @@ -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"})
Expand All @@ -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:
Expand All @@ -98,27 +106,41 @@ 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
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW
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:
if client.is_daemon_alive():
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)


Expand Down Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions livetools/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
95 changes: 65 additions & 30 deletions livetools/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand All @@ -719,20 +747,27 @@ def _cleanup(self) -> None:

def main() -> None:
if len(sys.argv) < 2:
print("Usage: python -m livetools.server <target_name_or_pid>", file=sys.stderr)
print("Usage: python -m livetools.server <target_name_or_pid> [--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

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__":
Expand Down
Loading