Add unblob so REA can extract firmware

unblob 26.6.4 (the version REA is verified with) is installed from its
PyPI wheel into /opt/unblob, with dependencies pinned to its uv.lock at
the release commit. A wrapper puts that venv first on PATH, and
REA_UNBLOB_COMMAND points REA's extract_firmware at it.

Its extractors follow upstream's install-deps.sh: Ubuntu packages,
sasquatch v4.5.1-6 (also used by binwalk) and onekey-sec's e2fsprogs
fork, whose debugfs rdump options the ext2/3/4 handler needs. The build
checks that unblob finds every extractor.

The uv.lock converter moves to scripts/ and skips the locked project
itself generically, since ghidra-mcp and unblob both use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 18:20:01 +01:00
co-authored by Claude Opus 5.5
parent e61c004431
commit ab9bb3eae9
4 changed files with 80 additions and 26 deletions
+4 -4
View File
@@ -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
+62 -11
View File
@@ -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.
+7 -6
View File
@@ -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
@@ -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")