diff --git a/CLAUDE.md b/CLAUDE.md index f6637ad..5ecc23a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ The goal is that `docker compose up` gives you a working setup where Claude Desk ## Status -Early stage. What exists: a single image (`Dockerfile`, `launch-apps.sh`) with Claude Desktop, Ghidra (plus GhidraMCP and an ESP32 loader), binwalk, esptool and REA, started by `run.sh` as two containers (Claude Desktop + Ghidra, and the REA MCP server); `README.md` covers usage; Claude Desktop's MCP config is `claude-desktop/claude_desktop_config.json`; Ghidra config files are in `ghidra/`. No compose setup yet. The layout below is the **intended** structure. Update this file when the real structure differs. +Early stage. What exists: a single image (`Dockerfile`, `launch-apps.sh`) with Claude Desktop, Ghidra (plus GhidraMCP and an ESP32 loader), binwalk, unblob, esptool and REA, started by `run.sh` as two containers (Claude Desktop + Ghidra, and the REA MCP server); `README.md` covers usage; Claude Desktop's MCP config is `claude-desktop/claude_desktop_config.json`; Ghidra config files are in `ghidra/`. No compose setup yet. The layout below is the **intended** structure. Update this file when the real structure differs. ## Intended layout @@ -42,13 +42,13 @@ Early stage. What exists: a single image (`Dockerfile`, `launch-apps.sh`) with C - **GUI forwarding.** Claude Desktop and Ghidra are GUI apps. Default to X11 forwarding (`/tmp/.X11-unix` + `DISPLAY`). Support Wayland through XWayland. Keep a noVNC/VNC fallback in mind for headless hosts. Never solve display access with a blanket `xhost +`. Scope it (`xhost +local:docker` or `+SI:localuser:...`). - **GhidraMCP wiring.** LaurieWired's GhidraMCP (https://github.com/LaurieWired/GhidraMCP), release 2.0, installed from the official release zip (SHA-256 checked). It has two parts: - a Ghidra extension, unpacked into `/opt/ghidra/Ghidra/Extensions/GhidraMCP`. Its `GhidraMCPPlugin` is a RELEASED plugin in the Developer package, so the CodeBrowser config loads it. While a CodeBrowser is open it serves an HTTP backend on `127.0.0.1:8080` (port: *Edit → Tool Options → GhidraMCP HTTP Server*). - - the `ghidra-mcp` Python MCP server (2.0 replaced `bridge_mcp_ghidra.py`), installed from the release wheel into the venv `/opt/ghidra-mcp`. Its dependencies are pinned to GhidraMCP's `uv.lock` at the release commit via `ghidra/uvlock-to-constraints.py`. CLI: `ghidra-mcp --ghidra-server http://127.0.0.1:8080/` (stdio by default; also `--transport streamable-http`, `--headless`). + - the `ghidra-mcp` Python MCP server (2.0 replaced `bridge_mcp_ghidra.py`), installed from the release wheel into the venv `/opt/ghidra-mcp`. Its dependencies are pinned to GhidraMCP's `uv.lock` at the release commit via `scripts/uvlock-to-constraints.py`. CLI: `ghidra-mcp --ghidra-server http://127.0.0.1:8080/` (stdio by default; also `--transport streamable-http`, `--headless`). A program must be open in the CodeBrowser for the tools to return data. Claude Desktop only starts MCP servers after sign-in. - **ESP32 Flash Loader** (https://github.com/dynacylabs/ghidra-esp32-flash-loader): a Ghidra loader ("ESP32 Flash Image" in *File → Import File*) for ESP32 app images and flash dumps. It reads the partition table at `0x8000`, maps segments to their addresses, adds peripherals from Espressif's SVD files and loads the chip's ROM ELF for symbols. A flash dump is only recognized when it **starts with the bootloader** (ESP32-S/C layouts). Original-ESP32 dumps (bootloader at `0x1000`) are rejected, and trimming the first `0x1000` bytes doesn't help: the loader then finds no partition table at `0x8000`, loads only the bootloader and throws a NullPointerException while still reporting success. Workaround: cut the app partition out of the dump (offsets from the partition table) and import it as an app image. Its tags ship no binaries and the older forks (tslater2006, Ebiroll, saibotk v1.1.0) don't compile against Ghidra 12, so the `esp32-loader-build` stage builds dynacylabs' master at a pinned commit, with its `data/svd` and `ghidra_scripts` submodules fetched over HTTPS at pinned commits and Gradle checked against gradle.org's SHA-256. It is unpacked into `/opt/ghidra/Ghidra/Extensions/esp32`; loaders need no tool config. -- **REA ("Reverse Engineer Anything")**: https://github.com/morluto/rea (MIT, TypeScript), the `rea-agents` npm package, pinned to **6.8.0** with the tarball checked against the registry's integrity hash; its dependencies are locked by the `npm-shrinkwrap.json` it ships. It needs Node.js ≥24.22.1 (official tarball, SHA-256 checked, in `/opt/node`) and procps' `ps`. It uses Ghidra 12.1.x headless through `GHIDRA_INSTALL_DIR` (its own temporary projects, separate from the GUI's) and binwalk 3.1.x through `REA_BINWALK_COMMAND`; Hopper, IDA, Kuna, JADX, unblob, tshark, pwntools and a browser are not installed, and frida (optional, runtime instrumentation) stays unbuilt because npm blocks its install script. Check with `rea doctor --provider ghidra --json`. REA's bundled skill (`reverse-engineer-anything`) is copied from the package into `~/.claude/skills` in the image, for Claude Code sessions in Claude Desktop's Code tab (they load user skills from there). Claude Desktop's chat doesn't read that folder: its skills come from the account (Settings → Capabilities → Skills) and are cached by the app. +- **REA ("Reverse Engineer Anything")**: https://github.com/morluto/rea (MIT, TypeScript), the `rea-agents` npm package, pinned to **6.8.0** with the tarball checked against the registry's integrity hash; its dependencies are locked by the `npm-shrinkwrap.json` it ships. It needs Node.js ≥24.22.1 (official tarball, SHA-256 checked, in `/opt/node`) and procps' `ps`. It uses Ghidra 12.1.x headless through `GHIDRA_INSTALL_DIR` (its own temporary projects, separate from the GUI's) binwalk 3.1.x through `REA_BINWALK_COMMAND` (`inspect_firmware_regions`) and unblob 26.6.x through `REA_UNBLOB_COMMAND` (`extract_firmware`); Hopper, IDA, Kuna, JADX, tshark, pwntools and a browser are not installed, and frida (optional, runtime instrumentation) stays unbuilt because npm blocks its install script. Check with `rea doctor --provider ghidra --json`. REA's bundled skill (`reverse-engineer-anything`) is copied from the package into `~/.claude/skills` in the image, for Claude Code sessions in Claude Desktop's Code tab (they load user skills from there). Claude Desktop's chat doesn't read that folder: its skills come from the account (Settings → Capabilities → Skills) and are cached by the app. Some REA tools run the target (process capture, runtime/Electron observation), so it does **not** run in the Claude Desktop container. `run.sh` starts a second container from the same image (`claude-re-mcp`): `rea mcp --http --host 0.0.0.0 --port 8765`, only the workspace mounted, no display, `--cap-drop ALL`, `no-new-privileges`, and only on the `--internal` network `claude-re-setup-internal` (no route out) under the alias `rea`. The Claude Desktop container is created on the default network plus that internal one, so it can sign in and reach `http://rea:8765/mcp`. REA's HTTP endpoint has no authentication and serves one client session at a time; the internal network is what limits who can reach it. A detached `docker wait` started by `run.sh` stops REA when Claude Desktop's container ends. -- **binwalk and esptool** are CLI tools in the same image (no MCP server yet). The `Dockerfile` has a `binwalk-build` stage adapted from binwalk's own Dockerfile (https://github.com/ReFirmLabs/binwalk) to Ubuntu 24.04, with every source pinned: binwalk at a master commit (3.1.1; the last tag, v3.1.0, is from 2024), Rust toolchain, `dumpifs`/`lzfse`/`dmg2img` commits, and the Python extractors (`jefferson`, `ubi-reader`, `uefi_firmware`, `vmlinux-to-elf`, ...) in `/opt/binwalk-py`. esptool is in `/opt/esptool`. The venvs' entry points are symlinked into `/usr/local/bin` (not their `python`/`pip`). The final stage adds upstream's runtime extractors from apt, sasquatch from onekey-sec's `.deb`, and Kaleido for `binwalk -E -p` (PNG entropy graphs), found via `KALEIDO_PATH`. Upstream publishes no checksums for sasquatch or Kaleido; their SHA-256s were recorded when pinned, amd64 only. `BINWALK_RM_EXTRACTION_SYMLINK=1` avoids dangling symlinks to container paths in extraction output. esptool needs the serial device passed into the container (`--device`) to talk to hardware; that isn't set up. +- **binwalk, unblob and esptool** are CLI tools in the same image (no MCP server of their own; REA wraps binwalk and unblob). The `Dockerfile` has a `binwalk-build` stage adapted from binwalk's own Dockerfile (https://github.com/ReFirmLabs/binwalk) to Ubuntu 24.04, with every source pinned: binwalk at a master commit (3.1.1; the last tag, v3.1.0, is from 2024), Rust toolchain, `dumpifs`/`lzfse`/`dmg2img` commits, and the Python extractors (`jefferson`, `ubi-reader`, `uefi_firmware`, `vmlinux-to-elf`, ...) in `/opt/binwalk-py`. esptool is in `/opt/esptool`. unblob (https://github.com/onekey-sec/unblob) 26.6.4 is installed from its PyPI wheel (SHA-256 checked) into `/opt/unblob`, with dependencies pinned to its `uv.lock` at the release commit via `scripts/uvlock-to-constraints.py`; `/usr/local/bin/unblob` is a wrapper that puts that venv first on `PATH`, so unblob uses its own locked `jefferson`/`ubireader` rather than binwalk's. Its extractors follow upstream's `install-deps.sh`: Ubuntu packages, sasquatch v4.5.1-6 (shared with binwalk) and onekey-sec's e2fsprogs fork (v1.47.0-3.ok2, replacing Ubuntu's 1.47.0), whose `debugfs rdump` options unblob's ext2/3/4 handler needs. The build fails unless `unblob --show-external-dependencies` finds every extractor. unblob's Landlock sandbox works in the locked-down REA container (`status=FullyEnforced`). The venvs' entry points are symlinked into `/usr/local/bin` (not their `python`/`pip`). The final stage adds upstream's runtime extractors from apt, sasquatch from onekey-sec's `.deb`, and Kaleido for `binwalk -E -p` (PNG entropy graphs), found via `KALEIDO_PATH`. Upstream publishes no checksums for sasquatch, onekey's e2fsprogs or Kaleido; their SHA-256s were recorded when pinned, amd64 only. `BINWALK_RM_EXTRACTION_SYMLINK=1` avoids dangling symlinks to container paths in extraction output. esptool needs the serial device passed into the container (`--device`) to talk to hardware; that isn't set up. - **MCP transport.** Claude Desktop starts local MCP servers as stdio subprocesses. GhidraMCP runs in the same container, so `ghidra-mcp` is a plain stdio entry in `claude_desktop_config.json` that reaches Ghidra on `127.0.0.1`. Servers in other containers (so far REA) serve streamable HTTP and are bridged into the stdio config with `mcp-remote` (`--allow-http --transport http-only`), installed in `/opt/mcp-bridge` from the committed `claude-desktop/mcp-bridge/package-lock.json` and started with `/opt/node/bin/node` by absolute path. Use the same pattern for future servers. Avoid mounting the Docker socket into the Claude Desktop container just to `docker exec` MCP servers. ## Conventions diff --git a/Dockerfile b/Dockerfile index 0f0d0e1..bf653ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -121,6 +121,28 @@ RUN python3 -m venv /opt/binwalk-py \ && /opt/esptool/bin/pip install --no-cache-dir --disable-pip-version-check \ "esptool==${ESPTOOL_VERSION}" +# unblob (https://github.com/onekey-sec/unblob), the firmware extractor REA +# uses for extract_firmware; REA is verified with 26.6.4. Installed from the +# PyPI wheel (SHA-256 from PyPI) into its own venv, with every dependency +# pinned to unblob's uv.lock at the release tag's commit, like ghidra-mcp. +ARG UNBLOB_VERSION=26.6.4 +ARG UNBLOB_COMMIT=1fcc7a0a584a70a96c31f5a276c20944d199a089 +ARG UNBLOB_WHEEL_SHA256=0fc16e9c7dc92fcf86b8b61475700c9dd16e1dddacf34467574ea965a5d3584e +ARG UNBLOB_UVLOCK_SHA256=0a88ed397d60e0624bdef34c7a0b7329a6d278dfdedb86f940c6ee25401c0317 +COPY scripts/uvlock-to-constraints.py /tmp/uvlock-to-constraints.py +RUN set -e \ + && whl="unblob-${UNBLOB_VERSION}-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" \ + && curl -fsSLo "/tmp/${whl}" "https://files.pythonhosted.org/packages/cp39/u/unblob/${whl}" \ + && echo "${UNBLOB_WHEEL_SHA256} /tmp/${whl}" | sha256sum -c - \ + && curl -fsSLo /tmp/uv.lock "https://raw.githubusercontent.com/onekey-sec/unblob/${UNBLOB_COMMIT}/uv.lock" \ + && echo "${UNBLOB_UVLOCK_SHA256} /tmp/uv.lock" | sha256sum -c - \ + && python3 -I /tmp/uvlock-to-constraints.py /tmp/uv.lock > /tmp/constraints.txt \ + && python3 -m venv /opt/unblob \ + && /opt/unblob/bin/pip install --no-cache-dir --disable-pip-version-check \ + --constraint /tmp/constraints.txt "/tmp/${whl}" +# (unblob needs libmagic to start, which this stage lacks; the final stage +# runs it.) + # --- Final image ------------------------------------------------------------- FROM ubuntu:24.04 @@ -207,7 +229,7 @@ ARG GHIDRAMCP_VERSION=2.0 ARG GHIDRAMCP_SHA256=ebf904fd6782e429e95a60a24fe0724b20c0acb2dedb304135a5281f6b5f2e72 ARG GHIDRAMCP_COMMIT=1e980252087b2479a21b9ca6d82d5829538651a7 ARG GHIDRAMCP_UVLOCK_SHA256=f80ffe8df8167535f253976a1dfb702a9a7f8836d884084017dd7c04e1444837 -COPY ghidra/uvlock-to-constraints.py /tmp/uvlock-to-constraints.py +COPY scripts/uvlock-to-constraints.py /tmp/uvlock-to-constraints.py RUN set -e \ && apt-get update \ && apt-get install -y --no-install-recommends python3 python3-venv \ @@ -230,13 +252,23 @@ RUN set -e \ && /opt/ghidra-mcp/bin/ghidra-mcp --help >/dev/null \ && rm -rf /tmp/ghidramcp /tmp/ghidramcp.zip /tmp/uv.lock /tmp/constraints.txt /tmp/uvlock-to-constraints.py -# binwalk (built above) with the extraction tools its Dockerfile installs. -# sasquatch (SquashFS variants) comes as a .deb from onekey-sec; the release -# publishes no checksums, so the SHA-256 was taken when it was pinned. +# binwalk and unblob (built above) with the extraction tools their install +# scripts (binwalk's Dockerfile, unblob's install-deps.sh) put in place. +# sasquatch (SquashFS variants) comes as a .deb from onekey-sec, at the +# release unblob 26.6.4 uses. unblob's ext2/3/4 extraction needs the rdump +# options from onekey-sec's e2fsprogs fork, so its .debs replace Ubuntu's +# e2fsprogs (same upstream 1.47.0). These releases publish no checksums; +# the SHA-256s were taken when pinned. # The Python tools' entry points are linked into /usr/local/bin; the venvs' -# python/pip are not, so the system python3 isn't shadowed. -ARG SASQUATCH_RELEASE=sasquatch-v4.5.1-5 -ARG SASQUATCH_SHA256_AMD64=23191cb530bed01e9f00b9a28a74d03db057fb111e13ce5fb383356c1fdf7e6c +# python/pip are not, so the system python3 isn't shadowed. unblob gets a +# wrapper that puts its own venv first on PATH, so it calls its own locked +# jefferson/ubireader rather than binwalk's. +ARG SASQUATCH_RELEASE=sasquatch-v4.5.1-6 +ARG SASQUATCH_SHA256_AMD64=efbc92bd27594e01211e1b582f105a463a0017360a0746a93de3f283906ade95 +ARG E2FSPROGS_OK_RELEASE=v1.47.0-3.ok2 +ARG LIBEXT2FS2_OK_SHA256_AMD64=4eaf27e79a570ec89e15d8851f7df693b8b3ae78ccbb5b2a4a7d3eee845f1beb +ARG LIBSS2_OK_SHA256_AMD64=6dfc4612915d885607f36fcea2b851a947f9c73943494208ec48a0cbe9887427 +ARG E2FSPROGS_OK_SHA256_AMD64=360adf4aa996cc80bf39002775d39900ae207b591c105d12a8ad2a833eb536f4 # binwalk -E -p (entropy graph as PNG) renders through Kaleido. binwalk's # build downloads it into the build stage's /root, which the image doesn't # have, so -p would crash; it's fetched here instead (same release the crate @@ -247,12 +279,23 @@ ARG KALEIDO_SHA256_AMD64=3eaa3efd41a00db05dd71add7f54ad4e9d4259552e3f7470ac4c1a8 COPY --from=binwalk-build /out/bin/ /usr/local/bin/ COPY --from=binwalk-build /opt/binwalk-py /opt/binwalk-py COPY --from=binwalk-build /opt/esptool /opt/esptool +COPY --from=binwalk-build /opt/unblob /opt/unblob RUN set -e \ && arch="$(dpkg --print-architecture)" \ && [ "$arch" = amd64 ] || { echo "sasquatch checksum only pinned for amd64" >&2; exit 1; } \ && curl -fsSLo /tmp/sasquatch.deb \ "https://github.com/onekey-sec/sasquatch/releases/download/${SASQUATCH_RELEASE}/sasquatch_1.0_${arch}.deb" \ && echo "${SASQUATCH_SHA256_AMD64} /tmp/sasquatch.deb" | sha256sum -c - \ + && mkdir /tmp/e2fsprogs-ok && cd /tmp/e2fsprogs-ok \ + && v="${E2FSPROGS_OK_RELEASE#v}" \ + && printf '%s %s\n' \ + "${LIBEXT2FS2_OK_SHA256_AMD64}" "libext2fs2_${v}_${arch}.deb" \ + "${LIBSS2_OK_SHA256_AMD64}" "libss2_${v}_${arch}.deb" \ + "${E2FSPROGS_OK_SHA256_AMD64}" "e2fsprogs_${v}_${arch}.deb" > SHA256SUMS \ + && for f in $(awk '{print $2}' SHA256SUMS); do \ + curl -fsSLO "https://github.com/onekey-sec/e2fsprogs/releases/download/${E2FSPROGS_OK_RELEASE}/$f"; \ + done \ + && sha256sum -c SHA256SUMS && cd / \ && curl -fsSLo /tmp/kaleido.zip \ "https://github.com/plotly/Kaleido/releases/download/v${KALEIDO_VERSION}/kaleido_linux_x64.zip" \ && echo "${KALEIDO_SHA256_AMD64} /tmp/kaleido.zip" | sha256sum -c - \ @@ -260,16 +303,23 @@ RUN set -e \ && rm /tmp/kaleido.zip \ && apt-get update \ && apt-get install -y --no-install-recommends \ - /tmp/sasquatch.deb \ + /tmp/sasquatch.deb /tmp/e2fsprogs-ok/*.deb \ 7zip 7zip-standalone zstd srecord tar unzip sleuthkit cabextract \ lz4 lzop unrar unyaffs cpio device-tree-compiler \ liblzo2-2 libucl1 libfontconfig1 \ - && rm -rf /var/lib/apt/lists/* /tmp/sasquatch.deb \ + android-sdk-libsparse-utils erofs-utils lziprecover partclone unar \ + upx-ucl libmagic1t64 xz-utils \ + && rm -rf /var/lib/apt/lists/* /tmp/sasquatch.deb /tmp/e2fsprogs-ok \ && for f in /opt/binwalk-py/bin/* /opt/esptool/bin/*; do \ case "$(basename "$f")" in python*|pip*|activate*|Activate*) continue ;; esac; \ ln -s "$f" /usr/local/bin/; \ done \ - && binwalk --version && esptool version + && printf '%s\n' '#!/bin/sh' 'PATH="/opt/unblob/bin:$PATH" exec /opt/unblob/bin/unblob "$@"' \ + > /usr/local/bin/unblob \ + && chmod 755 /usr/local/bin/unblob \ + && binwalk --version && esptool version && unblob --version \ + && debugfs -V 2>&1 | grep -q 1.47.0 \ + && unblob --show-external-dependencies # Binwalk leaves a symlink to the input file in its extraction directory. The # target is a container path, which is dangling when viewed from the host. ENV BINWALK_RM_EXTRACTION_SYMLINK=1 \ @@ -312,7 +362,8 @@ RUN set -e; cd /tmp \ && npm install --global --omit=optional --no-audit --no-fund "./rea-agents-${REA_VERSION}.tgz" \ && rm -rf "rea-agents-${REA_VERSION}.tgz" /root/.npm \ && [ "$(rea --version)" = "${REA_VERSION}" ] -ENV REA_BINWALK_COMMAND=/usr/local/bin/binwalk +ENV REA_BINWALK_COMMAND=/usr/local/bin/binwalk \ + REA_UNBLOB_COMMAND=/usr/local/bin/unblob # stdio-to-HTTP bridge (mcp-remote) that Claude Desktop starts to reach REA's # HTTP endpoint in the other container. Installed from a committed lockfile. diff --git a/README.md b/README.md index e9d3eb5..01d1c3e 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ A Docker-based reverse-engineering workstation for Linux. Claude Desktop is connected over MCP to Ghidra and to REA ("Reverse Engineer Anything"), and -binwalk and esptool are installed alongside them. Nothing beyond Docker and -an X server (or XWayland) is needed on the host. +binwalk, unblob and esptool are installed alongside them. Nothing beyond +Docker and an X server (or XWayland) is needed on the host. ## Usage @@ -20,10 +20,10 @@ Desktop or Ghidra's project window stops everything. ## What runs where -| Container | Contents | Network | -| ---------------- | ------------------------------------------------------------ | -------------------------- | -| `claude-desktop` | Claude Desktop, Ghidra GUI + GhidraMCP, binwalk, esptool | default + internal | -| `claude-re-mcp` | REA MCP server (headless Ghidra, binwalk), no display | internal only, no internet | +| Container | Contents | Network | +| ---------------- | --------------------------------------------------------------- | -------------------------- | +| `claude-desktop` | Claude Desktop, Ghidra GUI + GhidraMCP, binwalk, unblob, esptool | default + internal | +| `claude-re-mcp` | REA MCP server (headless Ghidra, binwalk, unblob), no display | internal only, no internet | Both containers run from the same image. REA gets its own container because some of its tools run the target. Claude Desktop reaches it at @@ -44,6 +44,7 @@ MCP servers in Claude Desktop (`claude-desktop/claude_desktop_config.json`): - REA 6.8.0 (Reverse Engineer Anything): https://github.com/morluto/rea, npm `rea-agents` - mcp-remote 0.15.1: https://github.com/punkpeye/mcp-remote (formerly geelen/mcp-remote) - binwalk 3.1.1: https://github.com/ReFirmLabs/binwalk +- unblob 26.6.4: https://github.com/onekey-sec/unblob - esptool 5.6.0: https://github.com/espressif/esptool Versions and checksums are pinned in the `Dockerfile`. `CLAUDE.md` has the diff --git a/ghidra/uvlock-to-constraints.py b/scripts/uvlock-to-constraints.py similarity index 52% rename from ghidra/uvlock-to-constraints.py rename to scripts/uvlock-to-constraints.py index 9517b70..0ba5b2f 100644 --- a/ghidra/uvlock-to-constraints.py +++ b/scripts/uvlock-to-constraints.py @@ -1,8 +1,10 @@ """Print pip constraints (name==version) for every package in a uv.lock. -Used at image build time to install the ghidra-mcp wheel with the exact -dependency versions its upstream lock file was tested with. A package that -uv resolved differently per Python version appears more than once; those +Used at image build time to install a Python tool's release wheel +(ghidra-mcp, unblob) with the exact dependency versions its upstream lock +file was tested with. The locked project itself (source "editable"/"virtual" +at ".") is skipped; it is installed from the wheel. A package that uv +resolved differently per Python version appears more than once; those entries carry their resolution markers, so pip applies only the one that matches the interpreter. """ @@ -13,8 +15,8 @@ with open(sys.argv[1], "rb") as f: lock = tomllib.load(f) for package in lock["package"]: - # The project itself is installed from the release wheel. - if package["name"] == "ghidra-mcp": + source = package.get("source", {}) + if source.get("editable") == "." or source.get("virtual") == ".": continue line = f'{package["name"]}=={package["version"]}' markers = package.get("resolution-markers")