diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 944f2e00..fa29eccc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,8 @@ on: branches: [ "main" ] pull_request: branches: [ "main" ] + types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: jobs: build: @@ -76,6 +78,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Validate installer script + run: | + bash -n scripts/install.sh + scripts/install.sh --help >/dev/null + scripts/install.sh --dry-run --no-install-deps --no-extension --no-angryghidra --prefix "$RUNNER_TEMP/triangr" - uses: actions/setup-python@v5 with: python-version: '3.12' @@ -89,3 +96,75 @@ jobs: pip install -r requirements-dev.txt - name: Run pytest run: python -m pytest tests/test_bridge.py -v + + installer-smoke: + # Exercises the real installer path, including Ghidra download, Python + # runtime install, angr importability, extension install, and AngryGhidra + # checkout/build. Kept separate from python-tests because it is intentionally + # heavier and benefits from cache. + runs-on: ubuntu-latest + env: + TRIANGR_PREFIX: /tmp/triangr + TRIANGR_CACHE: /home/runner/.cache/triangr-ci + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: requirements.txt + + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + + - uses: gradle/actions/setup-gradle@v4 + + - name: Cache installer downloads and Gradle artifacts + uses: actions/cache@v4 + with: + path: | + ${{ env.TRIANGR_CACHE }} + ~/.gradle/caches + ~/.gradle/wrapper + key: triangr-installer-${{ runner.os }}-ghidra-12.0.4-${{ hashFiles('requirements.txt', 'pom.xml', 'scripts/install.sh') }} + restore-keys: | + triangr-installer-${{ runner.os }}-ghidra-12.0.4- + + - name: Prime Python wheelhouse + run: | + mkdir -p "$TRIANGR_CACHE/wheels" "$TRIANGR_CACHE/pip" + python -m pip install --upgrade pip + python -m pip download --prefer-binary -r requirements.txt -d "$TRIANGR_CACHE/wheels" + + - name: Run full installer + run: | + mkdir -p "$TRIANGR_CACHE" + if [ -d "$TRIANGR_CACHE/tools" ]; then + mkdir -p "$TRIANGR_PREFIX" + cp -a "$TRIANGR_CACHE/tools" "$TRIANGR_PREFIX/tools" + fi + + PIP_CACHE_DIR="$TRIANGR_CACHE/pip" \ + PIP_FIND_LINKS="$TRIANGR_CACHE/wheels" \ + PIP_PREFER_BINARY=1 \ + scripts/install.sh --prefix "$TRIANGR_PREFIX" --install-deps --yes --require-angryghidra-build + + rm -rf "$TRIANGR_CACHE/tools" + mkdir -p "$TRIANGR_CACHE" + cp -a "$TRIANGR_PREFIX/tools" "$TRIANGR_CACHE/tools" + + - name: Verify installer outputs + run: | + source "$TRIANGR_PREFIX/env.sh" + test -x "$GHIDRA_HOME/ghidraRun" + test -d "$GHIDRA_HOME/Ghidra/Extensions/GhidraMCP" + test -x "$TRIANGR_PREFIX/bin/triangr-mcp" + test -d "$GHIDRA_HOME/Ghidra/Extensions/AngryGhidra" + "$GHIDRA_MCP_ANGR_PYTHON" -c "import angr; print(angr.__version__)" + "$GHIDRA_MCP_ANGR_PYTHON" -m bridge_mcp_ghidra --help >/dev/null + "$TRIANGR_PREFIX/bin/triangr-mcp" --help >/dev/null + test -f "$ANGRYGHIDRA_SCRIPT" diff --git a/README.md b/README.md index 02e0ef62..9d18e1b1 100644 --- a/README.md +++ b/README.md @@ -1,114 +1,230 @@ -> **This is a maintained fork** of the original -> [LaurieWired/GhidraMCP](https://github.com/LaurieWired/GhidraMCP), which has -> not received updates in roughly a year. New endpoints, bug fixes, and -> incorporated pull requests are listed in [CHANGELOG.md](CHANGELOG.md). -> Credit for the original work, design, and naming belongs to LaurieWired. +# Triangr + +**angr/Oxidizer decompilation + Ghidra project context + AI analysis** + +Triangr turns a live Ghidra project into an agent-ready reverse engineering +workbench. It is the maintained, hardened fork of +[LaurieWired/GhidraMCP](https://github.com/LaurieWired/GhidraMCP), expanded +from "LLM can ask Ghidra for decompiler text" into a three-way analysis loop: +Ghidra keeps the project context, angr answers program-analysis questions, and +MCP lets AI tools drive the workflow. + +Use it to inspect a binary, compare Ghidra and Oxidizer decompilation, trace how +to reach a branch, solve constraints at an address, lift blocks to VEX or AIL, +summarize CFGs and callgraphs, rename functions and variables, edit structures, +annotate paths, and patch bytes from the same MCP bridge. Optional parts stay +optional: Ghidra-only workflows do not require angr, core angr workflows do not +require AngryGhidra, and missing optional components return clear setup errors +instead of breaking the bridge. + +This fork preserves credit for the original work, design, and naming by +LaurieWired. New endpoints, hardening, and incorporated pull requests are listed +in [CHANGELOG.md](CHANGELOG.md). [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) -[![GitHub release (latest by date)](https://img.shields.io/github/v/release/LaurieWired/GhidraMCP)](https://github.com/LaurieWired/GhidraMCP/releases) -[![GitHub stars](https://img.shields.io/github/stars/LaurieWired/GhidraMCP)](https://github.com/LaurieWired/GhidraMCP/stargazers) -[![GitHub forks](https://img.shields.io/github/forks/LaurieWired/GhidraMCP)](https://github.com/LaurieWired/GhidraMCP/network/members) -[![GitHub contributors](https://img.shields.io/github/contributors/LaurieWired/GhidraMCP)](https://github.com/LaurieWired/GhidraMCP/graphs/contributors) +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/rustopian/GhidraMCP)](https://github.com/rustopian/GhidraMCP/releases) +[![GitHub stars](https://img.shields.io/github/stars/rustopian/GhidraMCP)](https://github.com/rustopian/GhidraMCP/stargazers) +[![GitHub forks](https://img.shields.io/github/forks/rustopian/GhidraMCP)](https://github.com/rustopian/GhidraMCP/network/members) +[![GitHub contributors](https://img.shields.io/github/contributors/rustopian/GhidraMCP)](https://github.com/rustopian/GhidraMCP/graphs/contributors) [![Follow @lauriewired](https://img.shields.io/twitter/follow/lauriewired?style=social)](https://twitter.com/lauriewired) ![ghidra_MCP_logo](https://github.com/user-attachments/assets/4986d702-be3f-4697-acce-aea55cd79ad3) +https://github.com/user-attachments/assets/36080514-f227-44bd-af84-78e29ee1d7f9 -# ghidraMCP -ghidraMCP is an Model Context Protocol server for allowing LLMs to autonomously reverse engineer applications. It exposes numerous tools from core Ghidra functionality to MCP clients. +## MCP + +Triangr is a Model Context Protocol server and Ghidra plugin. It works with MCP +clients that can launch a stdio server or connect to an SSE server, including +Claude Code, Codex, Claude Desktop, Cline, 5ire, and other MCP-capable AI tools. + +The Python bridge entrypoint remains `bridge_mcp_ghidra` for compatibility. The +installer also creates `~/.local/share/triangr/bin/triangr-mcp`, a wrapper that +loads the Triangr environment before starting the bridge, so GUI MCP clients do +not need hand-written `ANGRYGHIDRA_PYTHON` paths. The bridge speaks MCP on one +side and Ghidra's local HTTP plugin on the other, defaulting to +`http://127.0.0.1:8080/`. The Ghidra plugin binds to localhost by default, has a +configurable host and port, exposes a `/health` endpoint, and supports both +interactive and long-running decompilation workflows. + +## Ghidra Capabilities + +Triangr exposes the Ghidra project as structured AI tool context: + +- List and search functions, classes, imports, exports, strings, data, and xrefs. +- Decompile and disassemble by function or address, including async + decompilation with task polling and configurable timeouts for large functions. +- Rename functions and local variables, set function prototypes, set local + variable types, and preserve first-attempt variable rename reliability. +- Create, list, inspect, edit, and apply structures and enums using Ghidra's + built-in data type parser for complex C-style type expressions. +- Read bytes, write bytes, create functions, and force re-disassembly after + patches where possible. +- Set disassembly and decompiler comments, including preview-token guarded + overwrite flows so agents see the current comment and pending replacement + before applying destructive annotation writes. +- Check bridge health and watchdog status during long analysis sessions. + +## angr/Oxidizer Capabilities + +angr gives the bridge executable reasoning beyond static project inspection: + +- Decompile functions through angr's Oxidizer decompiler and compare Oxidizer + output against Ghidra decompiler output. +- Find symbolic paths to a target address, optionally avoiding addresses. +- Add JSON-described constraints at a found state and evaluate requested values. +- Check static reachability between addresses. +- Recover and summarize CFG and callgraph structure. +- Lift blocks to VEX or AIL for lower-level IR inspection. +- Use p-code support for targets such as Solana/eBPF, including Ghidra language + inference when available. +- Use AngryGhidra when installed for compatible symbolic execution workflows, + while falling back to the core angr helper when appropriate. + +The bridge looks for angr through `GHIDRA_MCP_ANGR_PYTHON`, the default +installer venv at `~/.local/share/triangr/venv`, local virtual environments, +then the Python running the MCP bridge. AngryGhidra support is detected through +`ANGRYGHIDRA_SCRIPT`, `ANGRYGHIDRA_HOME`, the default installer checkout under +`~/.local/share/triangr/AngryGhidra`, or a sibling `AngryGhidra` checkout. + +## Install Script + +### Prerequisites + +The installer supports Linux, WSL2, and macOS. It can install common packages +when a supported package manager is available: + +- Linux: `apt`, `dnf`, or `pacman` +- macOS: Homebrew +- WSL2: Linux package managers, with WSLg or another X server for the Ghidra GUI + +If you skip package-manager installation, install these yourself first: +Python 3.10+, `git`, `curl` or `wget`, `unzip`, JDK 21, and Maven. For Ghidra +extension builds, the installer uses the Gradle version required by the +downloaded Ghidra release rather than relying on an arbitrary system Gradle. + +### Using It + +```bash +git clone https://github.com/rustopian/GhidraMCP.git triangr +cd triangr +./scripts/install.sh +``` -https://github.com/user-attachments/assets/36080514-f227-44bd-af84-78e29ee1d7f9 +For a non-interactive install that also attempts system dependencies: + +```bash +./scripts/install.sh --install-deps --yes +``` + +Useful options: + +```bash +./scripts/install.sh --help +./scripts/install.sh --prefix ~/.local/share/triangr +./scripts/install.sh --no-angryghidra +./scripts/install.sh --no-extension +./scripts/install.sh --require-angryghidra-build +``` + +### What It Does + +The script defaults to user-owned paths under `~/.local/share/triangr` and can +be re-run safely. It: + +- Detects Linux, WSL2, or macOS. +- Prompts before using a package manager unless `--install-deps` or `--yes` is + supplied. +- Downloads Ghidra 12.0.4 by default. +- Creates a dedicated Python virtual environment. +- Installs the bridge runtime dependencies, including angr/Oxidizer. +- Installs the current checkout into that environment. +- Builds the Triangr Ghidra extension when Maven is available, otherwise falls + back to the latest release asset. +- Installs the extension into the downloaded Ghidra tree. +- Moves any existing extension folder aside with a `.old.` suffix + before replacing it. +- Clones AngryGhidra when requested and builds it with a Ghidra-compatible + Gradle, downloading that Gradle when needed. +- Writes `env.sh` with `GHIDRA_HOME`, `GHIDRA_MCP_ANGR_PYTHON`, + `ANGRYGHIDRA_HOME`, `ANGRYGHIDRA_SCRIPT`, and related paths. +- Writes `bin/triangr-mcp`, a wrapper suitable for MCP client configs. + +The script does not edit Claude, Codex, Cline, or other MCP client configs. + +### Verification + +After installation: + +```bash +source ~/.local/share/triangr/env.sh +bridge_mcp_ghidra --help +~/.local/share/triangr/bin/triangr-mcp --help +python -c "import angr; print(angr.__version__)" +"$GHIDRA_HOME/ghidraRun" +``` + +In Ghidra: + +1. Restart Ghidra if it was already open. +2. Open a program in CodeBrowser. +3. Enable the Triangr plugin under `File` -> `Configure` -> `Developer`. +4. Optional: enable AngryGhidra under `File` -> `Configure` -> `Miscellaneous`. +5. Check the bridge: + +```bash +curl http://127.0.0.1:8080/health +``` + +## Manual Installation + +Download the latest [release](https://github.com/rustopian/GhidraMCP/releases) +from this repository. It contains the Ghidra plugin and Python MCP client. +1. Run Ghidra. +2. Select `File` -> `Install Extensions`. +3. Click the `+` button. +4. Select the `GhidraMCP-.zip` extension archive. The archive name is + retained for compatibility during the Triangr rebrand. +5. Restart Ghidra. +6. Open a program in CodeBrowser. +7. Make sure the Triangr plugin is enabled in `File` -> `Configure` -> + `Developer`. +8. Optional: configure host and port under `Edit` -> `Tool Options` -> + `Triangr HTTP Server`. -# Features -MCP Server + Ghidra Plugin - -- Decompile and analyze binaries in Ghidra -- Automatically rename methods and data -- List methods, classes, imports, and exports -- Create and edit structure data types and pointers -- Create new functions at arbitrary addresses - -# Installation - -## Prerequisites -- Install [Ghidra](https://ghidra-sre.org) -- Python3 -- MCP [SDK](https://github.com/modelcontextprotocol/python-sdk) - -## Optional angr / AngryGhidra -This fork can expose angr/Oxidizer decompilation and AngryGhidra symbolic -execution without making either one a hard dependency for the normal Ghidra MCP -tools. - -- Install Python dependencies into an isolated environment: - `python3 -m venv .venv && .venv/bin/python -m pip install -r requirements.txt` -- `angr_decompile_function` uses `GHIDRA_MCP_ANGR_PYTHON` when set. Otherwise it - tries `.venv/bin/python`, then a sibling `GhidraMCP-fork/.venv/bin/python`, - then the interpreter running the MCP bridge. -- For Solana/eBPF ELFs, pass `pcode_language="eBPF:LE:64:default"` or let the - bridge infer it from Ghidra's program language id. The helper patches CLE at - runtime for Solana's e_machine 263 and uses angr's p-code engine. -- `angr_symbolic_find` defaults to `engine="auto"`: it uses AngryGhidra when - the script is installed and the request fits AngryGhidra's native symbolic - executor, then falls back to the core helper when needed. Use - `engine="angryghidra"` to require AngryGhidra or `engine="core"` to force the - direct helper. -- Additional core angr tools do not require AngryGhidra: - `angr_solve_constraints_at` adds JSON-described constraints at the found - state and evaluates requested values; `angr_reachability` checks static CFG - reachability; `angr_cfg_summary` and `angr_callgraph_summary` summarize - recovered graph structure; `angr_lift_block` lifts a block to VEX/AIL; and - `angr_compare_decompilers` batches Ghidra-vs-Oxidizer decompiler output. -- `angr_annotate_symbolic_path` previews by default and shows the current - comment that each planned annotation would overwrite alongside the pending - comment. To write the recovered trace as Ghidra disassembly and/or decompiler - comments, call it again with the same arguments, `apply=true`, - `overwrite_existing=true`, and the preview token from the reviewed dry run; - the underlying Ghidra comment endpoints replace existing comments. -- angr/AngryGhidra execution is bounded by conservative limits on helper output, - symbolic input sizes, symbolic steps, summary output, lift size, and batch - comparison size. -- AngryGhidra support is optional. `angryghidra_*` tools look for - `ANGRYGHIDRA_SCRIPT`, `ANGRYGHIDRA_HOME/angryghidra_script/angryghidra.py`, - or a sibling `AngryGhidra/angryghidra_script/angryghidra.py`. If none is - found, they return a clear error and all other tools continue to work. -- If launching AngryGhidra inside Ghidra, set `ANGRYGHIDRA_PYTHON` to the same - venv Python so its script uses the installed angr package. - -## Ghidra -First, download the latest [release](https://github.com/LaurieWired/GhidraMCP/releases) from this repository. This contains the Ghidra plugin and Python MCP client. Then, you can directly import the plugin into Ghidra. - -1. Run Ghidra -2. Select `File` -> `Install Extensions` -3. Click the `+` button -4. Select the `GhidraMCP-1-2.zip` (or your chosen version) from the downloaded release -5. Restart Ghidra -6. Open a program in the CodeBrowser -7. Make sure the GhidraMCPPlugin is enabled in `File` -> `Configure` -> `Developer` -8. *Optional*: Configure the port in Ghidra with `Edit` -> `Tool Options` -> `GhidraMCP HTTP Server` - -Video Installation Guide: - - -https://github.com/user-attachments/assets/75f0c176-6da1-48dc-ad96-c182eb4648c3 - -The Python MCP client can be installed with either `pipx install GhidraMCP` or `uv tool install GhidraMCP`. +The Python MCP client can be installed from this repository: + +```bash +pipx install git+https://github.com/rustopian/GhidraMCP.git +uv tool install git+https://github.com/rustopian/GhidraMCP.git +``` + +Or directly from a checkout: + +```bash +python3 -m venv .venv +.venv/bin/python -m pip install -r requirements.txt +.venv/bin/python -m pip install -e . +``` ## MCP Clients -Theoretically, any MCP client should work with ghidraMCP. Three examples are given below. +Any MCP client should work with Triangr. If you used the installer, prefer the +generated wrapper at `~/.local/share/triangr/bin/triangr-mcp`. It loads the +right angr and AngryGhidra environment automatically. + +### Claude Desktop -## Example 1: Claude Desktop -To set up Claude Desktop as a Ghidra MCP client, go to `Claude` -> `Settings` -> `Developer` -> `Edit Config` -> `claude_desktop_config.json` and add the following: +Go to `Claude` -> `Settings` -> `Developer` -> `Edit Config` -> +`claude_desktop_config.json` and add: ```json { "mcpServers": { - "ghidra": { - "command": "python", + "triangr": { + "command": "/Users/YOUR_USER/.local/share/triangr/bin/triangr-mcp", "args": [ - "/ABSOLUTE_PATH_TO/bridge_mcp_ghidra.py", "--ghidra-server", "http://127.0.0.1:8080/" ] @@ -117,49 +233,58 @@ To set up Claude Desktop as a Ghidra MCP client, go to `Claude` -> `Settings` -> } ``` -Alternatively, edit this file directly: -``` -/Users/YOUR_USER/Library/Application Support/Claude/claude_desktop_config.json -``` +If you are running from a checkout rather than an installed console script, use: -The server IP and port are configurable and should be set to point to the target Ghidra instance. If not set, both will default to localhost:8080. +```json +{ + "mcpServers": { + "triangr": { + "command": "python", + "args": [ + "/ABSOLUTE_PATH_TO/bridge_mcp_ghidra.py", + "--ghidra-server", + "http://127.0.0.1:8080/" + ] + } + } +} +``` -If the GhidraMCP Python client was installed with `pipx` or `uv tool`, the first argument can be replaced with `bridge_mcp_ghidra` instead of giving an absolute path. +### Cline -## Example 2: Cline -To use GhidraMCP with [Cline](https://cline.bot), this requires manually running the MCP server as well. First run the following command: +Run the MCP bridge over SSE: -``` -python bridge_mcp_ghidra.py --transport sse --mcp-host 127.0.0.1 --mcp-port 8081 --ghidra-server http://127.0.0.1:8080/ +```bash +bridge_mcp_ghidra --transport sse --mcp-host 127.0.0.1 --mcp-port 8081 --ghidra-server http://127.0.0.1:8080/ ``` -Or if the GhidraMCP Python client was installed with `pipx` or `uv tool`: +Or, with the installer wrapper: -``` -bridge_mcp_ghidra --transport sse --mcp-host 127.0.0.1 --mcp-port 8081 --ghidra-server http://127.0.0.1:8080/ +```bash +~/.local/share/triangr/bin/triangr-mcp --transport sse --mcp-host 127.0.0.1 --mcp-port 8081 --ghidra-server http://127.0.0.1:8080/ ``` +Then add a remote server in Cline: -The only *required* argument is the transport. If all other arguments are unspecified, they will default to the above. Once the MCP server is running, open up Cline and select `MCP Servers` at the top. +1. Server Name: `Triangr` +2. Server URL: `http://127.0.0.1:8081/sse` -![Cline select](https://github.com/user-attachments/assets/88e1f336-4729-46ee-9b81-53271e9c0ce0) +### 5ire -Then select `Remote Servers` and add the following, ensuring that the url matches the MCP host and port: +Open 5ire and go to `Tools` -> `New`: -1. Server Name: GhidraMCP -2. Server URL: `http://127.0.0.1:8081/sse` +1. Tool Key: `triangr` +2. Name: `Triangr` +3. Command: `/Users/YOUR_USER/.local/share/triangr/bin/triangr-mcp` -## Example 3: 5ire -Another MCP client that supports multiple models on the backend is [5ire](https://github.com/nanbingxyz/5ire). To set up GhidraMCP, open 5ire and go to `Tools` -> `New` and set the following configurations: +Use `python /ABSOLUTE_PATH_TO/bridge_mcp_ghidra.py` instead if running from a +checkout. -1. Tool Key: ghidra -2. Name: GhidraMCP -3. Command: `python /ABSOLUTE_PATH_TO/bridge_mcp_ghidra.py` +## Building from Source -If the GhidraMCP Python client was installed with `pipx` or `uv tool`, the command can be `bridge_mcp_ghidra` without needing to specify the python interpreter or giving an absolute path. +Copy the following files from your Ghidra directory to this project's `lib/` +directory: -# Building from Source -1. Copy the following files from your Ghidra directory to this project's `lib/` directory: - `Ghidra/Features/Base/lib/Base.jar` - `Ghidra/Features/Decompiler/lib/Decompiler.jar` - `Ghidra/Framework/Docking/lib/Docking.jar` @@ -168,12 +293,16 @@ If the GhidraMCP Python client was installed with `pipx` or `uv tool`, the comma - `Ghidra/Framework/SoftwareModeling/lib/SoftwareModeling.jar` - `Ghidra/Framework/Utility/lib/Utility.jar` - `Ghidra/Framework/Gui/lib/Gui.jar` -2. Build with Maven by running: -`mvn clean package assembly:single` +Build with Maven: + +```bash +mvn clean package assembly:single +``` -The generated zip file includes the built Ghidra plugin and its resources. These files are required for Ghidra to recognize the new extension. +The generated zip file includes the built Ghidra plugin and resources required +for Ghidra to recognize the extension: -- lib/GhidraMCP.jar -- extensions.properties -- Module.manifest +- `lib/GhidraMCP.jar`, retained as the compatibility artifact name +- `extension.properties` +- `Module.manifest` diff --git a/bridge_mcp_ghidra.py b/bridge_mcp_ghidra.py index cb34b724..d5597710 100644 --- a/bridge_mcp_ghidra.py +++ b/bridge_mcp_ghidra.py @@ -32,6 +32,14 @@ "angryghidra_script", "angryghidra.py", ) +DEFAULT_TRIANGR_HOME = os.path.expanduser("~/.local/share/triangr") +DEFAULT_TRIANGR_ANGR_PYTHON = os.path.join(DEFAULT_TRIANGR_HOME, "venv", "bin", "python") +DEFAULT_TRIANGR_ANGRYGHIDRA_SCRIPT = os.path.join( + DEFAULT_TRIANGR_HOME, + "AngryGhidra", + "angryghidra_script", + "angryghidra.py", +) PARENT_ANGRYGHIDRA_SCRIPT = os.path.join( os.path.dirname(BRIDGE_DIR), "AngryGhidra", @@ -167,10 +175,14 @@ def parse_key_value_lines(lines: list) -> dict: def default_angr_python() -> str: candidates = [ + DEFAULT_TRIANGR_ANGR_PYTHON, os.path.join(BRIDGE_DIR, ".venv", "bin", "python"), os.path.join(BRIDGE_DIR, "GhidraMCP-fork", ".venv", "bin", "python"), sys.executable, ] + triangr_home = os.environ.get("TRIANGR_HOME") + if triangr_home: + candidates.insert(0, os.path.join(triangr_home, "venv", "bin", "python")) for candidate in candidates: if candidate and os.path.isfile(candidate): return candidate @@ -216,9 +228,18 @@ def find_angryghidra_script() -> str: candidates = [ os.environ.get("ANGRYGHIDRA_SCRIPT", ""), os.path.join(os.environ.get("ANGRYGHIDRA_HOME", ""), "angryghidra_script", "angryghidra.py"), + DEFAULT_TRIANGR_ANGRYGHIDRA_SCRIPT, DEFAULT_ANGRYGHIDRA_SCRIPT, PARENT_ANGRYGHIDRA_SCRIPT, ] + triangr_home = os.environ.get("TRIANGR_HOME") + if triangr_home: + candidates.insert(2, os.path.join( + triangr_home, + "AngryGhidra", + "angryghidra_script", + "angryghidra.py", + )) candidates.extend(glob.glob(os.path.expanduser( "~/Library/ghidra/*/Extensions/AngryGhidra/angryghidra_script/angryghidra.py" ))) diff --git a/pyproject.toml b/pyproject.toml index 8a22f0ee..82b46ed1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,12 +5,13 @@ build-backend = "hatchling.build" [project] name = "ghidramcp" dynamic = ["version"] -description = "Model Context Protocol server for Ghidra reverse engineering" +description = "Triangr MCP bridge for Ghidra project context and optional angr/Oxidizer analysis" readme = "README.md" requires-python = ">=3.10" license = "Apache-2.0" authors = [ - {name = "LaurieWired"} + {name = "LaurieWired"}, + {name = "Triangr contributors"}, ] keywords = ["ghidra", "reverse-engineering", "mcp", "model-context-protocol"] classifiers = [ @@ -35,9 +36,9 @@ angr = [ ] [project.urls] -Homepage = "https://github.com/LaurieWired/GhidraMCP" -Repository = "https://github.com/LaurieWired/GhidraMCP" -Issues = "https://github.com/LaurieWired/GhidraMCP/issues" +Homepage = "https://github.com/rustopian/GhidraMCP" +Repository = "https://github.com/rustopian/GhidraMCP" +Issues = "https://github.com/rustopian/GhidraMCP/issues" [project.scripts] bridge_mcp_ghidra = "bridge_mcp_ghidra:main" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 00000000..05612fd4 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,547 @@ +#!/usr/bin/env bash +set -euo pipefail + +GHIDRA_VERSION="12.0.4" +GHIDRA_DATE="20260303" +GHIDRA_URL="" +GHIDRA_REPO="NationalSecurityAgency/ghidra" +FORK_REPO_URL="https://github.com/rustopian/GhidraMCP.git" +FORK_REPO_REF="main" +ANGRYGHIDRA_REPO="https://github.com/Nalen98/AngryGhidra.git" + +PREFIX="${HOME}/.local/share/triangr" +REPO_DIR="" +INSTALL_DEPS="ask" +INSTALL_ANGRYGHIDRA="yes" +INSTALL_TRIANGR_EXTENSION="yes" +REQUIRE_ANGRYGHIDRA_BUILD="no" +DRY_RUN="no" +YES="no" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" >/dev/null 2>&1 && pwd || pwd)" +SCRIPT_REPO_DIR="" +if [[ -f "${SCRIPT_DIR}/../bridge_mcp_ghidra.py" ]]; then + SCRIPT_REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +fi + +usage() { + cat <&2; usage; exit 2 ;; + esac +done + +if [[ -z "$REPO_DIR" ]]; then + if [[ -n "$SCRIPT_REPO_DIR" ]]; then + REPO_DIR="$SCRIPT_REPO_DIR" + else + REPO_DIR="$PREFIX/source/Triangr" + fi +fi + +GHIDRA_ZIP="ghidra_${GHIDRA_VERSION}_PUBLIC_${GHIDRA_DATE}.zip" +if [[ -z "$GHIDRA_URL" ]]; then + GHIDRA_URL="https://github.com/${GHIDRA_REPO}/releases/download/Ghidra_${GHIDRA_VERSION}_build/${GHIDRA_ZIP}" +fi + +confirm() { + local prompt="$1" + if [[ "$YES" == "yes" ]]; then + return 0 + fi + read -r -p "${prompt} [y/N] " answer + [[ "$answer" =~ ^[Yy]$ ]] +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 +} + +run_sudo() { + if [[ "$(id -u)" -eq 0 ]]; then + "$@" + else + sudo "$@" + fi +} + +detect_os() { + local uname_s + uname_s="$(uname -s)" + if [[ "$uname_s" == "Darwin" ]]; then + echo "macos" + elif [[ "$uname_s" == "Linux" ]]; then + if grep -qi microsoft /proc/version 2>/dev/null; then + echo "wsl2" + else + echo "linux" + fi + else + echo "unsupported" + fi +} + +install_os_deps() { + local os="$1" + if [[ "$INSTALL_DEPS" == "ask" ]] && ! confirm "Install common prerequisites with the system package manager?"; then + INSTALL_DEPS="no" + fi + [[ "$INSTALL_DEPS" == "yes" ]] || return 0 + + if [[ "$os" == "macos" ]]; then + if ! need_cmd brew; then + echo "Homebrew is not installed. Install Homebrew first or re-run with --no-install-deps." >&2 + return 1 + fi + brew install python git curl unzip openjdk@21 maven + elif need_cmd apt-get; then + run_sudo apt-get update + run_sudo apt-get install -y python3 python3-venv python3-pip git curl unzip openjdk-21-jdk maven build-essential + elif need_cmd dnf; then + run_sudo dnf install -y python3 python3-pip git curl unzip java-21-openjdk-devel maven gcc gcc-c++ make + elif need_cmd pacman; then + run_sudo pacman -Sy --needed python python-pip git curl unzip jdk21-openjdk maven base-devel + else + echo "No supported package manager found. Install Python 3.10+, git, curl, unzip, JDK 21, and Maven manually." >&2 + fi +} + +download() { + local url="$1" + local output="$2" + if need_cmd curl; then + curl -L --fail --retry 3 -o "$output" "$url" + elif need_cmd wget; then + wget -O "$output" "$url" + else + echo "curl or wget is required to download ${url}" >&2 + return 1 + fi +} + +github_latest_asset_url() { + local repo="$1" + local pattern="$2" + python3 - "$repo" "$pattern" <<'PY' +import json +import re +import sys +import urllib.request + +repo, pattern = sys.argv[1], sys.argv[2] +url = f"https://api.github.com/repos/{repo}/releases/latest" +req = urllib.request.Request(url, headers={"User-Agent": "triangr-installer"}) +with urllib.request.urlopen(req, timeout=30) as response: + release = json.load(response) +for asset in release.get("assets", []): + name = asset.get("name", "") + if re.search(pattern, name): + print(asset["browser_download_url"]) + break +else: + raise SystemExit(f"No release asset matched {pattern!r} in {repo}") +PY +} + +ensure_repo_checkout() { + if [[ -f "$REPO_DIR/bridge_mcp_ghidra.py" ]]; then + echo "Using Triangr checkout at $REPO_DIR" + return 0 + fi + + if [[ -e "$REPO_DIR" ]]; then + echo "$REPO_DIR exists but does not look like this MCP fork. Use --repo-dir or move it aside." >&2 + return 1 + fi + + mkdir -p "$(dirname "$REPO_DIR")" + echo "Cloning Triangr source from $FORK_REPO_URL" + git clone --depth 1 --branch "$FORK_REPO_REF" "$FORK_REPO_URL" "$REPO_DIR" +} + +install_ghidra() { + local tools_dir="$PREFIX/tools" + local ghidra_dir="$tools_dir/ghidra_${GHIDRA_VERSION}_PUBLIC" + local zip_path="$tools_dir/$GHIDRA_ZIP" + mkdir -p "$tools_dir" + + if [[ -x "$ghidra_dir/ghidraRun" ]]; then + echo "Ghidra already installed at $ghidra_dir" + return 0 + fi + + echo "Downloading Ghidra ${GHIDRA_VERSION}" + download "$GHIDRA_URL" "$zip_path" + unzip -q "$zip_path" -d "$tools_dir" +} + +version_at_least() { + python3 - "$1" "$2" <<'PY' +import re +import sys + +def parts(version): + values = [int(part) for part in re.findall(r"\d+", version)[:3]] + while len(values) < 3: + values.append(0) + return values + +sys.exit(0 if parts(sys.argv[1]) >= parts(sys.argv[2]) else 1) +PY +} + +ghidra_gradle_min_version() { + local ghidra_home="$1" + local props="$ghidra_home/Ghidra/application.properties" + local version="" + + if [[ -f "$props" ]]; then + version="$(awk -F= '$1 == "application.gradle.min" { print $2; exit }' "$props" | tr -d '[:space:]')" + fi + + echo "${TRIANGR_GRADLE_VERSION:-${version:-8.5}}" +} + +gradle_command_supports() { + local gradle_cmd="$1" + local minimum="$2" + local current="" + + current="$("$gradle_cmd" --version 2>/dev/null | awk '/^Gradle / { print $2; exit }')" || return 1 + [[ -n "$current" ]] || return 1 + version_at_least "$current" "$minimum" +} + +ensure_gradle_for_ghidra() { + local ghidra_home="$1" + local version + local tools_dir="$PREFIX/tools" + local gradle_dir + local zip_path + local system_gradle="" + + version="$(ghidra_gradle_min_version "$ghidra_home")" + gradle_dir="$tools_dir/gradle-$version" + zip_path="$tools_dir/gradle-$version-bin.zip" + + if [[ -x "$gradle_dir/bin/gradle" ]]; then + echo "$gradle_dir/bin/gradle" + return 0 + fi + + if need_cmd gradle; then + system_gradle="$(command -v gradle)" + if gradle_command_supports "$system_gradle" "$version"; then + echo "$system_gradle" + return 0 + fi + fi + + mkdir -p "$tools_dir" + echo "Downloading Gradle $version for Ghidra extension builds" >&2 + download "https://services.gradle.org/distributions/gradle-${version}-bin.zip" "$zip_path" + unzip -q -o "$zip_path" -d "$tools_dir" + echo "$gradle_dir/bin/gradle" +} + +install_python_env() { + local venv="$PREFIX/venv" + if [[ ! -x "$venv/bin/python" ]]; then + python3 -m venv "$venv" + fi + "$venv/bin/python" -m pip install --upgrade pip wheel setuptools + "$venv/bin/python" -m pip install -r "$REPO_DIR/requirements.txt" + "$venv/bin/python" -m pip install -e "$REPO_DIR" +} + +copy_ghidra_libs_for_build() { + local ghidra_home="$PREFIX/tools/ghidra_${GHIDRA_VERSION}_PUBLIC" + local libs=( + "Features/Base/lib/Base.jar" + "Features/Decompiler/lib/Decompiler.jar" + "Framework/Docking/lib/Docking.jar" + "Framework/Generic/lib/Generic.jar" + "Framework/Project/lib/Project.jar" + "Framework/SoftwareModeling/lib/SoftwareModeling.jar" + "Framework/Utility/lib/Utility.jar" + "Framework/Gui/lib/Gui.jar" + ) + + mkdir -p "$REPO_DIR/lib" + for lib in "${libs[@]}"; do + cp "$ghidra_home/Ghidra/$lib" "$REPO_DIR/lib/" + done +} + +find_extension_zip() { + local search_root="$1" + find "$search_root" -type f -name 'GhidraMCP-*.zip' | sort | tail -n 1 +} + +build_triangr_extension() { + if ! need_cmd mvn; then + return 1 + fi + copy_ghidra_libs_for_build + mvn -q -f "$REPO_DIR/pom.xml" clean package assembly:single +} + +download_triangr_extension() { + local downloads_dir="$PREFIX/downloads" + local output="$downloads_dir/triangr-extension.zip" + local asset_url + mkdir -p "$downloads_dir" + asset_url="$(github_latest_asset_url "rustopian/GhidraMCP" 'GhidraMCP.*\.zip$')" + download "$asset_url" "$output" + echo "$output" +} + +install_extension_zip() { + local zip_path="$1" + local extension_dir_name="$2" + local ghidra_home="$PREFIX/tools/ghidra_${GHIDRA_VERSION}_PUBLIC" + local extensions_dir="$ghidra_home/Ghidra/Extensions" + local dest="$extensions_dir/$extension_dir_name" + local tmp="$PREFIX/tmp/install-${extension_dir_name}-$$" + local source_dir="" + local props_file="" + local inner_zip="" + + rm -rf "$tmp" + mkdir -p "$tmp" "$extensions_dir" + unzip -q "$zip_path" -d "$tmp" + + if [[ -f "$tmp/$extension_dir_name/extension.properties" ]]; then + source_dir="$tmp/$extension_dir_name" + else + props_file="$(find "$tmp" -type f -name extension.properties -print -quit || true)" + if [[ -n "$props_file" ]]; then + source_dir="$(dirname "$props_file")" + fi + fi + + if [[ -z "$source_dir" ]]; then + inner_zip="$(find "$tmp" -type f -name '*.zip' -print -quit || true)" + if [[ -n "$inner_zip" ]]; then + rm -rf "$tmp/inner" + mkdir -p "$tmp/inner" + unzip -q "$inner_zip" -d "$tmp/inner" + if [[ -f "$tmp/inner/$extension_dir_name/extension.properties" ]]; then + source_dir="$tmp/inner/$extension_dir_name" + else + props_file="$(find "$tmp/inner" -type f -name extension.properties -print -quit || true)" + if [[ -n "$props_file" ]]; then + source_dir="$(dirname "$props_file")" + fi + fi + fi + fi + + if [[ -z "$source_dir" || ! -f "$source_dir/extension.properties" ]]; then + echo "Could not find a Ghidra extension layout inside $zip_path" >&2 + return 1 + fi + + if [[ -e "$dest" ]]; then + local backup="${dest}.old.$(date +%Y%m%d%H%M%S)" + echo "Existing $extension_dir_name extension found. Moving it to $backup" + mv "$dest" "$backup" + fi + + cp -R "$source_dir" "$dest" + echo "Installed $extension_dir_name extension into $dest" +} + +install_triangr_extension() { + [[ "$INSTALL_TRIANGR_EXTENSION" == "yes" ]] || return 0 + local zip_path="" + + echo "Building Triangr extension from this checkout" + if build_triangr_extension; then + zip_path="$(find_extension_zip "$REPO_DIR/target")" + fi + + if [[ -z "$zip_path" ]]; then + zip_path="$(find_extension_zip "$REPO_DIR/target" || true)" + fi + + if [[ -z "$zip_path" ]]; then + echo "No local extension ZIP found. Downloading the latest release asset." + zip_path="$(download_triangr_extension)" + fi + + install_extension_zip "$zip_path" "GhidraMCP" +} + +install_angryghidra() { + [[ "$INSTALL_ANGRYGHIDRA" == "yes" ]] || return 0 + local dest="$PREFIX/AngryGhidra" + local ghidra_home="$PREFIX/tools/ghidra_${GHIDRA_VERSION}_PUBLIC" + local gradle_cmd="" + local zip_path="" + + if [[ -d "$dest/.git" ]]; then + if [[ -z "$(git -C "$dest" status --short)" ]]; then + git -C "$dest" pull --ff-only || echo "Could not fast-forward AngryGhidra. Leaving existing checkout in place." >&2 + else + echo "AngryGhidra checkout has local changes. Leaving it untouched at $dest" + fi + else + git clone "$ANGRYGHIDRA_REPO" "$dest" + fi + + if ! gradle_cmd="$(ensure_gradle_for_ghidra "$ghidra_home")"; then + echo "Could not install a Ghidra-compatible Gradle. AngryGhidra was cloned but not built." >&2 + [[ "$REQUIRE_ANGRYGHIDRA_BUILD" == "yes" ]] && return 1 + return 0 + fi + + echo "Building AngryGhidra extension" + if (cd "$dest" && GHIDRA_INSTALL_DIR="$ghidra_home" "$gradle_cmd" --quiet); then + zip_path="$(find "$dest" -type f -name '*AngryGhidra*.zip' | sort | tail -n 1)" + if [[ -n "$zip_path" ]]; then + install_extension_zip "$zip_path" "AngryGhidra" + else + echo "AngryGhidra built, but no extension ZIP was found under $dest." >&2 + [[ "$REQUIRE_ANGRYGHIDRA_BUILD" == "yes" ]] && return 1 + fi + else + echo "AngryGhidra build failed. The source checkout remains at $dest." >&2 + [[ "$REQUIRE_ANGRYGHIDRA_BUILD" == "yes" ]] && return 1 + fi +} + +write_env() { + local env_file="$PREFIX/env.sh" + local ghidra_home="$PREFIX/tools/ghidra_${GHIDRA_VERSION}_PUBLIC" + mkdir -p "$PREFIX" + cat > "$env_file" < "$wrapper" <&2 + exit 1 + fi + + if [[ "$DRY_RUN" == "yes" ]]; then + cat < Configure -> Developer. + 4. Enable AngryGhidra under File -> Configure -> Miscellaneous if you want its UI. + +MCP bridge: + $PREFIX/bin/triangr-mcp + +Useful environment: + $PREFIX/env.sh +EOF +} + +main "$@" diff --git a/src/main/java/com/lauriewired/GhidraMCPPlugin.java b/src/main/java/com/lauriewired/GhidraMCPPlugin.java index 5928932c..f8bf5f82 100644 --- a/src/main/java/com/lauriewired/GhidraMCPPlugin.java +++ b/src/main/java/com/lauriewired/GhidraMCPPlugin.java @@ -70,13 +70,13 @@ status = PluginStatus.RELEASED, packageName = ghidra.app.DeveloperPluginPackage.NAME, category = PluginCategoryNames.ANALYSIS, - shortDescription = "HTTP server plugin", - description = "Starts an embedded HTTP server to expose program data. Port configurable via Tool Options." + shortDescription = "Triangr HTTP server", + description = "Starts an embedded HTTP server to expose program data to MCP clients. Port configurable via Tool Options." ) public class GhidraMCPPlugin extends Plugin { private HttpServer server; - private static final String OPTION_CATEGORY_NAME = "GhidraMCP HTTP Server"; + private static final String OPTION_CATEGORY_NAME = "Triangr HTTP Server"; private static final String PORT_OPTION_NAME = "Server Port"; private static final int DEFAULT_PORT = 8080; private static final String HOST_OPTION_NAME = "Server Host IP/NAME"; @@ -99,7 +99,7 @@ public class GhidraMCPPlugin extends Plugin { Math.max(2, Runtime.getRuntime().availableProcessors()), r -> { Thread t = new Thread(r); - t.setName("GhidraMCP-Async-" + t.getId()); + t.setName("Triangr-Async-" + t.getId()); t.setDaemon(true); return t; }); @@ -174,7 +174,7 @@ private void evictOldestTaskIfFull() { public GhidraMCPPlugin(PluginTool tool) { super(tool); - Msg.info(this, "GhidraMCPPlugin loading..."); + Msg.info(this, "Triangr plugin loading..."); // Register the configuration option Options options = tool.getOptions(OPTION_CATEGORY_NAME); @@ -193,7 +193,7 @@ public GhidraMCPPlugin(PluginTool tool) { catch (IOException e) { Msg.error(this, "Failed to start HTTP server", e); } - Msg.info(this, "GhidraMCPPlugin loaded!"); + Msg.info(this, "Triangr plugin loaded!"); } private void startServer() throws IOException { @@ -817,12 +817,12 @@ private void startServer() throws IOException { serverStartTime = System.currentTimeMillis(); lastRequestTimestamp = serverStartTime; startWatchdog(); - Msg.info(this, "GhidraMCP HTTP server started on port " + port); + Msg.info(this, "Triangr HTTP server started on port " + port); } catch (Exception e) { Msg.error(this, "Failed to start HTTP server on port " + port + ". Port might be in use.", e); server = null; } - }, "GhidraMCP-HTTP-Server").start(); + }, "Triangr-HTTP-Server").start(); } // ---------------------------------------------------------------------------------- @@ -2948,7 +2948,7 @@ private void startWatchdog() { try { Thread.sleep(WATCHDOG_INTERVAL_MS); runWatchdogCheck(); } catch (InterruptedException e) { break; } } - }, "GhidraMCP-Watchdog"); + }, "Triangr-Watchdog"); watchdogThread.setDaemon(true); watchdogThread.start(); } @@ -2988,10 +2988,10 @@ public void dispose() { } asyncTasks.clear(); if (server != null) { - Msg.info(this, "Stopping GhidraMCP HTTP server..."); + Msg.info(this, "Stopping Triangr HTTP server..."); server.stop(1); server = null; - Msg.info(this, "GhidraMCP HTTP server stopped."); + Msg.info(this, "Triangr HTTP server stopped."); } super.dispose(); } diff --git a/src/main/resources/META-INF/MANIFEST.MF b/src/main/resources/META-INF/MANIFEST.MF index 40adf302..514df545 100644 --- a/src/main/resources/META-INF/MANIFEST.MF +++ b/src/main/resources/META-INF/MANIFEST.MF @@ -1,6 +1,6 @@ Manifest-Version: 1.0 Plugin-Class: com.lauriewired.GhidraMCPPlugin -Plugin-Name: GhidraMCP +Plugin-Name: Triangr Plugin-Version: 1.6.1 -Plugin-Author: LaurieWired -Plugin-Description: A custom plugin by LaurieWired +Plugin-Author: LaurieWired, Triangr contributors +Plugin-Description: Ghidra context plus optional angr analysis. diff --git a/src/main/resources/extension.properties b/src/main/resources/extension.properties index 5a9b3f25..ab73d5c7 100644 --- a/src/main/resources/extension.properties +++ b/src/main/resources/extension.properties @@ -1,6 +1,6 @@ -name=GhidraMCP -description=A plugin that runs an embedded HTTP server to expose program data. -author=LaurieWired +name=Triangr +description=angr/Oxidizer decompilation, Ghidra project context, and AI analysis. +author=LaurieWired, Triangr contributors createdOn=2025-03-22 version=12.0.4 ghidraVersion=12.0.4 diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 5acc6bae..0ccd51e7 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -243,6 +243,47 @@ def test_disassemble_function(self, bridge_module, httpx_mock): class TestAngrIntegrations: + def test_default_angr_python_prefers_triangr_home( + self, bridge_module, tmp_path, monkeypatch): + triangr_home = tmp_path / "triangr" + python_path = triangr_home / "venv" / "bin" / "python" + python_path.parent.mkdir(parents=True) + python_path.write_text("") + + monkeypatch.setenv("TRIANGR_HOME", str(triangr_home)) + + assert bridge_module.default_angr_python() == str(python_path) + + def test_default_angr_python_checks_default_install_without_relative_probe( + self, bridge_module, monkeypatch): + checked = [] + monkeypatch.delenv("TRIANGR_HOME", raising=False) + monkeypatch.setattr( + bridge_module, + "DEFAULT_TRIANGR_ANGR_PYTHON", + "/home/user/.local/share/triangr/venv/bin/python") + + def fake_isfile(path): + checked.append(path) + return False + + monkeypatch.setattr(bridge_module.os.path, "isfile", fake_isfile) + + assert bridge_module.default_angr_python() == bridge_module.sys.executable + assert "venv/bin/python" not in checked + assert "/home/user/.local/share/triangr/venv/bin/python" in checked + + def test_find_angryghidra_script_uses_triangr_home( + self, bridge_module, tmp_path, monkeypatch): + script = tmp_path / "triangr" / "AngryGhidra" / "angryghidra_script" / "angryghidra.py" + script.parent.mkdir(parents=True) + script.write_text("# test") + + monkeypatch.setenv("TRIANGR_HOME", str(tmp_path / "triangr")) + monkeypatch.setattr(bridge_module.glob, "glob", lambda _pattern: []) + + assert bridge_module.find_angryghidra_script() == str(script) + def test_angr_decompile_uses_program_info_defaults( self, bridge_module, httpx_mock, monkeypatch): httpx_mock.add_response(