ghidra-cli/PRD.md
hermes 5c81b469e5
Some checks failed
checks / flake (push) Has been cancelled
fix: make help and version successful discovery
2026-07-29 04:15:53 +00:00

64 KiB

Product Requirements Document

ghidra-cli

  • Status: Draft
  • Revision: 0.1
  • Audience: maintainers and early users
  • Repository name: ghidra-cli
  • Executable name: ghidr
  • License: Apache-2.0

Summary

ghidra-cli is a small Rust command-line interface for reproducible, read-only analysis and decompilation through Ghidra. It hides Ghidra project management and Java process details behind commands that accept a sample path directly.

The repository retains the descriptive ghidra-cli name even though another public project uses the same name for a similar purpose. The installed executable will use a shorter distinct name. This implementation remains independent and does not copy source from that GPL-3.0 project.

The project is developed with AI assistance and human review. AI assistance does not relax provenance, licensing, security, testing, or maintainership requirements. Maintainers are responsible for every accepted contribution.

The first release favors a narrow, reliable workflow over a broad command surface. Automation agents are the primary consumers of its interface contract. Human-readable terminal output is a first-class presentation of the same underlying results, but it does not take priority over predictable machine behavior.

Problem

Ghidra supports headless analysis, but its native interface exposes concepts such as project directories, project names, import and process modes, script paths, and JVM configuration. Answering a simple question about a sample therefore requires orchestration knowledge unrelated to reverse engineering.

Existing wrappers commonly address this by becoming persistent services with background JVMs, sockets, job queues, project lifecycle commands, custom query languages, and mutating operations. Those capabilities are useful in mature interactive systems, but they increase the number of states that can fail and make a small tool harder to trust.

Product principles

  1. A sample path is enough. Users do not manage Ghidra projects.
  2. Read-only by default and throughout the first release.
  3. Content determines identity. Moving or renaming a sample does not invalidate compatible analysis.
  4. Structured output is a public interface, not formatted log text.
  5. Every failure is explicit, typed, and actionable.
  6. No background process is required for the initial command set.
  7. Concurrency is introduced only with a concrete concurrent workflow.
  8. Ghidra remains the analysis engine; Rust owns orchestration and policy.
  9. Automation behavior takes priority when machine and terminal conveniences conflict.
  10. Bounded output must disclose its bounds; truncation is never implicit.
  11. Existing Unix tools perform ad hoc filtering; the CLI does not invent a query language.

Users

Automation agent

The primary consumer. It needs deterministic commands, bounded output, stable JSON schemas, meaningful exit codes, zero ambiguous resolution, and enough provenance to explain where a result came from. It can compose JSON output with standard tools such as jq before placing selected results in model context.

Human analyst

Wants to inspect an unfamiliar executable, discover functions, and decompile a selected function without opening the Ghidra GUI or learning its project model. Human-readable output presents the same semantics as the automation contract.

Maintainer

Needs a small dependency graph, reproducible fixtures, a clear Java boundary, and compatibility failures that are discovered by tests rather than users.

Goals for the first release

  • Diagnose whether the local Ghidra and JDK installation is usable.
  • Inspect basic sample and analysis metadata.
  • List discovered functions with stable identifiers.
  • Decompile one selected function.
  • Reuse compatible analysis without requiring user-managed project names.
  • Inspect and explicitly clean tool-owned stored data.
  • Emit concise terminal output and versioned JSON.
  • Bound Ghidra execution by configurable time and memory limits.
  • Operate without network access after dependencies are installed.
  • Run on x86-64 Linux through a pinned Nix package.

Non-goals for the first release

  • Replacing Ghidra's decompiler or analysis engine.
  • A daemon, TCP bridge, HTTP service, or MCP server.
  • Multiple concurrent Ghidra analyses.
  • An interactive shell.
  • Binary patching or exporting modified executables.
  • Renaming symbols, applying types, or changing function signatures.
  • Arbitrary Java or Python script execution.
  • A custom filtering or query language.
  • Manual Ghidra loader, processor-language, or compiler-specification overrides.
  • Raw Samples that require manual Target Specification.
  • Automatically downloading Ghidra or a JDK.
  • Managing user-visible Ghidra projects.
  • Automatically evicting Analyses or Artifacts.
  • Claiming support for untested host platforms.
  • Supporting every historical Ghidra version.

Initial platform and distribution

Version 0.1 officially supports x86_64-linux. The implementation should keep host-specific behavior isolated, but successful compilation on another host does not constitute support without passing the real Ghidra integration suite.

The supported installation is a pinned Nix flake package containing compatible versions of ghidra-cli, Ghidra 12.1.2, and JDK 21. The flake pins a nixpkgs revision that provides those versions; it does not float with a registry or channel. Users can run the package without a global installation:

nix run <flake-reference> -- doctor

macOS is the next intended platform. It becomes supported only after CI or a maintainer-owned Mac runs the same integration fixtures used on Linux. Other Linux architectures and non-Nix installation methods are deferred.

Version 0.1 has no persistent configuration file. Runtime policy is expressed through documented CLI flags and narrowly scoped environment variables. A configuration file is deferred until repeated real-world usage demonstrates which settings deserve persistence.

Verified Sample targets

Version 0.1 verifies these Sample targets through real Ghidra integration fixtures and reviewed golden outputs:

  • ELF, x86-64 little-endian.
  • PE32+, x86-64.

The supported host and verified Sample target are independent: the x86-64 Linux host analyzes both Linux and Windows executable formats. Mach-O, ARM64, firmware images, raw binaries, and other Ghidra targets are unverified in version 0.1.

A Target Specification that Ghidra recognizes but the version 0.1 matrix does not cover is analyzed on a best-effort basis. Success remains success, but response provenance includes:

{
  "target": {
    "loader": "<loader-name>",
    "format": "<format-name>",
    "processor_language": "<language-id>",
    "compiler_specification": "<compiler-spec-id>",
    "verification": "unverified"
  },
  "warnings": [
    {
      "code": "unverified_target",
      "message": "target is outside the verified integration-test matrix",
      "details": {
        "verified_targets": [
          "elf-x86_64-little-endian",
          "pe32plus-x86_64"
        ]
      }
    }
  ]
}

verification describes this project's integration coverage, not Ghidra's confidence in its own result. Failure by Ghidra to recognize a Target Specification remains a structured error. Ambiguous auto-detection also fails and returns bounded candidate Target Specifications; the CLI never chooses one silently.

Ghidra upgrades are deliberate reviewed changes. An upgrade must change the pinned nixpkgs revision, pass the complete real-Ghidra integration suite, and explain any protocol schema or golden-fixture differences before merge.

Command-line experience

Installation diagnosis

$ ghidr --format human doctor
Ghidra 12.1.2: ready
JDK 21: ready
Analysis store: writable

doctor must not modify a Sample or create an Analysis. It may create and remove a temporary file inside the Analysis Store to verify writability. It reports the store path and total disk usage.

Successful doctor response data has this version 0.1 shape:

{
  "ready": true,
  "components": {
    "ghidra": {
      "status": "ready",
      "version": "12.1.2",
      "launcher": {
        "encoding": "utf8",
        "value": "/nix/store/.../bin/ghidra-analyzeHeadless"
      }
    },
    "java": {
      "status": "ready",
      "version": "21",
      "executable": {
        "encoding": "utf8",
        "value": "/nix/store/.../bin/java"
      }
    },
    "analysis_store": {
      "status": "ready",
      "path": {
        "encoding": "utf8",
        "value": "/home/user/ghidr-store"
      },
      "source": "home_fallback",
      "usage": {
        "logical_bytes": 1048576,
        "allocated_bytes": 1114112
      }
    },
    "sandbox": {
      "status": "ready",
      "backend": "bubblewrap",
      "verification": "verified"
    }
  }
}

Each component status is exactly one of ready, missing, incompatible, invalid, or unwritable. The aggregate ready is true only when every required component is ready. doctor runs every safe check instead of stopping at the first failure, and it removes its Analysis Store write probe before returning.

Storage usage is always represented by both logical_bytes, the sum of file lengths, and allocated_bytes, the sum of Linux st_blocks * 512 including directory allocation. Store scans use lstat and never follow symlinks.

A healthy report is a success document on stdout with exit status 0. If any required component is not ready, doctor emits a doctor_failed error on stderr with exit status 1; error.details.report contains the same complete shape with ready set to false. This preserves strict JSON framing while allowing one invocation to diagnose every component.

Inspect a sample

$ ghidr --format human inspect ./sample
Sample: 8d969eef...
Format: ELF
Language: x86:LE:64
Functions: 42

If no compatible Analysis exists, the command creates one automatically. The same bytes at another path reuse the Analysis. Different bytes at the same path create a different Analysis.

Successful inspect response data has this minimal version 0.1 shape:

{
  "sample": {
    "sha256": "<64-character digest>",
    "size_bytes": 123456
  },
  "program": {
    "image_base": {
      "space": "ram",
      "offset": "0x0000000000400000"
    },
    "minimum_address": {
      "space": "ram",
      "offset": "0x0000000000400000"
    },
    "maximum_address": {
      "space": "ram",
      "offset": "0x0000000000404fff"
    },
    "function_count": 42
  },
  "analysis": {
    "disposition": "created"
  }
}

The three Address fields are nullable when Ghidra has no applicable memory Address. function_count includes memory, external, and thunk Functions: the same population returned by functions. analysis.disposition is exactly one of created, reused, or rebuilt. Executable format, processor language, compiler specification, Ghidra version, and Analysis Profile digest live in the common response provenance and are not duplicated here.

List functions

$ ghidr --format human functions ./sample
ADDRESS           SIZE  NAME
0x00401000         112  _start
0x00401120         248  main

The command returns a bounded result set and discloses whether additional results exist. It does not implement a filtering expression language in the first release; JSON consumers can use existing tools such as jq.

functions includes every Ghidra Function, including external/imported functions and thunks. Each item explicitly states location, is_external, is_thunk, and decompilable. External functions remain visible for program structure but are not valid decompilation targets.

The minimal version 0.1 function-list item is:

{
  "name": "parse",
  "qualified_name": "Widget::parse",
  "entry": {
    "space": "ram",
    "offset": "0x0000000000401120"
  },
  "body_address_count": 248,
  "location": "memory",
  "is_external": false,
  "is_thunk": false,
  "thunk_target_entry": null,
  "decompilable": true
}

thunk_target_entry is an Address when Ghidra resolves a thunk target and is otherwise null. Version 0.1 deliberately omits signatures, parameters, locals, call counts, and analyzer-derived heuristics from this collection. Those details require targeted Queries rather than making every list item larger and less stable.

The functions Query uses this deterministic total order:

  1. Functions whose location is memory, followed by functions whose location is external.
  2. entry.space in byte-for-byte ascending order.
  3. entry.offset in unsigned numeric ascending order.

Ghidra permits only one Function at an entry Address within an Analysis, so the Address completes the ordering and no symbol-name tie-breaker is required. The Query reports the order identifier location_then_entry_ascending.

name is Ghidra's exact Function basename (getName(false)). qualified_name is Ghidra's exact fully qualified Function name (getName(true)), including its :: namespace spelling. The CLI preserves case, UTF-8 bytes, generated names such as FUN_00401120, and analyzer-applied demangling. It does not independently demangle, simplify templates, strip namespaces, or Unicode-normalize names.

Function Selector comparison uses those exact strings. Even a qualified name that resolves to multiple Functions produces the normal ambiguity error with entry Addresses. Structured namespaces and alternate symbol names are deferred to a targeted symbol or Function-detail Query.

Collection Queries share these flags:

ghidr functions ./sample --limit 100
ghidr functions ./sample --limit 100 --offset 100
ghidr functions ./sample --all
ghidr decompile ./sample --name main --max-inline-bytes 1048576
  • --limit <COUNT> sets the maximum number of returned items.
  • --offset <COUNT> skips that many items in the documented stable order.
  • --all explicitly requests every item.
  • Omitting both --limit and --all applies a default limit of 100 items.
  • --all cannot be combined with --limit or --offset.
  • A zero --limit is invalid; --all is the only spelling for unbounded collection output.
  • Invalid combinations fail as invalid arguments rather than choosing an implicit precedence.
  • --max-inline-bytes <BYTES> overrides the 65,536-byte inline budget with a positive integer measured strictly in bytes.
  • --max-inline-bytes 0, unit suffixes, and an unbounded-inline sentinel are invalid.

Collection JSON includes explicit page metadata:

{
  "page": {
    "order": "location_then_entry_ascending",
    "offset": 0,
    "limit": 100,
    "returned": 100,
    "total": 342,
    "has_more": true
  },
  "items": []
}

The ordering is part of the Query contract. Repeating a page against the same immutable Analysis returns the same items. Human-readable output ends with a concise notice when more results exist.

Oversized results

Item limits alone do not bound response size because one item, such as a decompiled function or extracted string, may be very large. Every response is therefore also subject to a serialized-byte policy. The default successful inline budget is 65,536 bytes of serialized output.

The CLI never truncates a JSON document or silently removes fields to meet that budget. When a complete result would exceed it, the CLI writes the result atomically as an Artifact in the Analysis Store and returns a successful, bounded descriptor:

{
  "schema_version": 1,
  "kind": "decompilation",
  "provenance": {
    "ghidr_version": "0.1.0",
    "adapter_protocol_version": 1,
    "ghidra_version": "12.1.2",
    "java_version": "21",
    "sample_sha256": "<digest>",
    "source_path": {
      "encoding": "utf8",
      "value": "./sample"
    },
    "analysis_profile_sha256": "<digest>",
    "target": {
      "loader": "<loader-name>",
      "format": "<format-name>",
      "processor_language": "<language-id>",
      "compiler_specification": "<compiler-spec-id>",
      "verification": "verified"
    },
    "analysis_store": {
      "path": {
        "encoding": "utf8",
        "value": "/home/user/ghidr-store"
      },
      "source": "home_fallback"
    },
    "sandbox": {
      "backend": "bubblewrap",
      "verification": "verified"
    },
    "limits": {
      "max_heap_mib": 2048,
      "max_cpu": 2,
      "analysis_timeout_seconds": 600,
      "decompile_timeout_seconds": 60,
      "child_watchdog_seconds": 780
    }
  },
  "data": {
    "spilled": true,
    "artifact": {
      "path": {
        "encoding": "utf8",
        "value": "/home/user/ghidr-store/artifacts/<sample>/<profile>/<digest>.json"
      },
      "bytes": 2097152,
      "sha256": "<artifact-digest>",
      "media_type": "application/json",
      "contains": "complete_success_response"
    }
  },
  "warnings": []
}

Spilling is not an error: the requested result was produced in full and remains available to local tools such as jq and rg. Human-readable output reports the Artifact path, size, and digest instead of printing partial content.

The Artifact contains the complete success response that would otherwise have been written to stdout, including schema_version, kind, provenance, command data, and warnings. It uses UTF-8 JSON without a byte-order mark, the same compact serializer as stdout, and exactly one trailing LF. The reported bytes and sha256 cover the exact file bytes, including that LF.

Artifact creation is atomic. A completed Artifact is immutable until explicit cleanup and never recursively spills regardless of its own size. Version 0.1 does not add an Artifact-reading command: local automation reads the tagged absolute path directly with tools such as jq and may verify the SHA-256 before use. contains is exactly complete_success_response.

The caller's inline budget applies strictly to successful stdout documents. Before publishing an Artifact, the CLI serializes its prospective descriptor. If that descriptor exceeds the selected budget, the CLI removes the temporary Artifact and fails with inline_budget_too_small. Typed error details contain configured_bytes and the exact required_bytes; retrying at that value or higher can return the descriptor.

Error documents use an independent fixed 65,536-byte serialized safety cap. This guarantees that a bounded stderr response can explain why no valid success document fit. A successful document never exceeds --max-inline-bytes.

--all removes the collection item bound; it does not remove the serialized byte budget. Only --max-inline-bytes <BYTES> changes that budget.

Decompile one function

Successful decompile response data has this minimal version 0.1 shape:

{
  "requested_selector": {
    "kind": "name",
    "value": "main"
  },
  "function": {
    "name": "main",
    "qualified_name": "main",
    "entry": {
      "space": "ram",
      "offset": "0x0000000000401120"
    }
  },
  "decompilation": {
    "syntax": "ghidra_c",
    "text": "int main(void) {\n  return 0;\n}\n"
  }
}

requested_selector records the exact selector kind and value supplied by the caller. function records the unique Function that selector resolved to. syntax is ghidra_c because the text is Ghidra's C-like representation and is not guaranteed to be valid ISO C. Decompiler warnings use the response envelope's normal warnings collection rather than being embedded into the text. A timeout or failure never returns partial decompilation text.

The adapter normalizes CRLF and bare CR line endings in decompilation.text to LF. It preserves every other character exactly as returned by Ghidra, including indentation, comments, blank lines, trailing spaces, and the presence or absence of a final newline. Version 0.1 does not reformat generated code. A future formatter or richer representation may be added in response to demonstrated usage, but it must be an explicit contract change rather than an unannounced alteration of text.

Clean stored data

ghidr clean ./sample
ghidr clean ./sample --dry-run
ghidr clean --digest <64-character-sha256>
ghidr clean --all
ghidr clean --all --dry-run
ghidr clean --all --yes

The first release never evicts an Analysis or Artifact automatically. Cleanup is an explicit user request. clean --all requires interactive confirmation; non-interactive callers must provide --yes deliberately.

clean <SAMPLE> hashes the current Sample bytes, then removes every Analysis and Artifact for that Sample digest across all Analysis Profiles. It never removes data for another Sample. Cleanup reports the affected Analysis Profiles and the logical and allocated usage associated with removed targets.

clean --digest <SHA256> provides the same Sample-wide behavior when the original Sample is no longer available. The digest must contain exactly 64 hexadecimal characters. <SAMPLE>, --digest, and --all are mutually exclusive cleanup targets; specifying none or more than one is an invalid argument.

Cleanup executes by default. --dry-run reports the exact targets and their logical and allocated usage without deleting anything. Sample- and digest-specific cleanup requires no confirmation because the destructive verb and target are both explicit.

Store-wide cleanup follows the selected output format:

  • JSON mode never prompts. Without --yes, it fails with confirmation_required; the typed error details include the prospective item counts and usage. The caller may then repeat the request with --yes.
  • Human mode prompts only when attached to a terminal.
  • Human mode without a terminal requires --yes.
  • --dry-run never prompts and never deletes in either format.

Successful cleanup response data has this version 0.1 shape:

{
  "mode": "executed",
  "target": {
    "kind": "sample",
    "source_path": {
      "encoding": "utf8",
      "value": "./sample"
    },
    "sha256": "<sample digest>"
  },
  "matched": {
    "analyses": 2,
    "quarantined_analyses": 1,
    "artifacts": 3,
    "diagnostic_logs": 4,
    "usage": {
      "logical_bytes": 5242880,
      "allocated_bytes": 5308416
    },
    "analysis_profile_sha256": [
      "<digest-a>",
      "<digest-b>"
    ]
  },
  "removed": {
    "analyses": 2,
    "quarantined_analyses": 1,
    "artifacts": 3,
    "diagnostic_logs": 4,
    "usage": {
      "logical_bytes": 5242880,
      "allocated_bytes": 5308416
    }
  }
}

mode is exactly executed or dry_run. target is a tagged union: sample contains the caller's source Path and resolved SHA-256, digest contains the supplied SHA-256, and all has no additional fields. matched records the preflight snapshot. Its Analysis Profile digests are unique and bytewise sorted. For a dry run, removed is null.

A successful execution reports exact removed counts, including Quarantined Analyses and Diagnostic Logs. Matching nothing is a successful result with zero counts. A large cleanup report uses the normal Artifact-spill behavior. The reported allocation is the preflight allocation associated with the successfully deleted targets, not a claim about the filesystem's observed free-space delta; compression, reflinks, metadata, and concurrent activity can make those values differ.

Cleanup uses a recoverable two-phase transaction:

  1. Resolve every target and acquire its lock in deterministic digest order.
  2. If any lock is held, fail with analysis_busy before changing store data.
  3. Atomically rename each target into a tool-owned cleanup transaction directory on the same filesystem.
  4. If any staging rename fails, roll back completed renames and return an error.
  5. Delete staged targets only after every rename succeeds.
  6. Report success only after every staged target has actually been deleted.

If deletion fails after partial progress, return retryable cleanup_incomplete. Typed details include exact removed and remaining item counts, logical and allocated usage, and the cleanup transaction's tagged absolute Path. Transaction contents can never satisfy a Query. A later matching clean resumes deletion of the transaction before cleaning newly matched active data. This provides recoverable interruption behavior even though deleting multiple filesystem objects cannot be made truly atomic.

Decompile a function

$ ghidr --format human decompile ./sample --name main
int main(int argc, char **argv)
{
    ...
}

Exactly one explicit Function Selector is required:

ghidr decompile ./sample --name main
ghidr decompile ./sample --address ram:0x0000000000401120

--name performs exact symbol resolution. An ambiguous symbol is an error and returns bounded candidates instead of silently choosing one. --address requires an explicit address space and canonical hexadecimal offset. The flags are mutually exclusive; positional selector guessing is unsupported.

--address must equal a function entry Address. An interior Address fails with function_entry_required; typed error details include the containing function's entry when Ghidra identifies one. Version 0.1 does not provide a containing-address selector.

--name is exact and case-sensitive. A qualified name matches only that full qualified name. An unqualified name matches exact basenames across namespaces: one match succeeds, zero fails with function_not_found, and multiple fail with function_selector_ambiguous. Version 0.1 performs no fuzzy, glob, regex, or demangled-substring matching.

Structured output

$ ghidr inspect ./sample

JSON is the unconditional default. Human-readable output is explicit:

$ ghidr --format human inspect ./sample

JSON uses a versioned envelope:

{
  "schema_version": 1,
  "kind": "inspection",
  "provenance": {
    "ghidr_version": "0.1.0",
    "adapter_protocol_version": 1,
    "ghidra_version": "12.1.2",
    "java_version": "21",
    "sample_sha256": "<digest>",
    "source_path": {
      "encoding": "utf8",
      "value": "./sample"
    },
    "analysis_profile_sha256": "<digest>",
    "target": {
      "loader": "<loader-name>",
      "format": "<format-name>",
      "processor_language": "<language-id>",
      "compiler_specification": "<compiler-spec-id>",
      "verification": "verified"
    },
    "analysis_store": {
      "path": {
        "encoding": "utf8",
        "value": "/home/user/ghidr-store"
      },
      "source": "xdg"
    },
    "sandbox": {
      "backend": "bubblewrap",
      "verification": "verified"
    },
    "limits": {
      "max_heap_mib": 2048,
      "max_cpu": 2,
      "analysis_timeout_seconds": 600,
      "decompile_timeout_seconds": null,
      "child_watchdog_seconds": 720
    }
  },
  "data": {},
  "warnings": []
}

Every Sample-backed Query (inspect, functions, and decompile) uses this fixed provenance shape. analysis_store.source is exactly one of cli, environment, xdg, or home_fallback. target.verification is exactly verified or unverified. sandbox.backend is exactly bubblewrap, external, or off, and its verification is verified, unverified, or disabled. decompile_timeout_seconds is null for a Query with no decompilation phase. Limits report selected policy even when a compatible Analysis is reused; the derived watchdog records the bound for the current command. Command-specific facts remain in data.

Every path exposed through JSON uses a tagged Path object. A valid UTF-8 path is represented without alteration:

{
  "encoding": "utf8",
  "value": "/tmp/sample"
}

A Linux path containing non-UTF-8 bytes uses standard padded Base64 over its exact Unix byte sequence:

{
  "encoding": "unix_bytes_base64",
  "value": "L3RtcC9zYW1wbGX/"
}

Caller-supplied relative Sample paths remain relative and byte-exact in provenance. Tool-resolved locations, including the Analysis Store, Artifacts, and Diagnostic Logs, are absolute. Human output renders non-UTF-8 bytes with an explicit escaped representation.

JSON mode is a strict framing protocol:

  • On success, stdout contains exactly one JSON document and stderr is empty.
  • On failure, stdout is empty and stderr contains exactly one JSON error document.
  • Progress is suppressed.
  • Ghidra and adapter diagnostics are written as Diagnostic Logs in the Analysis Store rather than mixed into either stream.
  • An error includes the path, byte size, and content digest of its Diagnostic Log when one is available.

CLI discovery is the sole framing exception. ghidr --help, every ghidr <command> --help, and ghidr --version are successful metadata requests rather than operations: they emit plain UTF-8 text on stdout, leave stderr empty, exit with status 0, and never initialize Ghidra, access the Analysis Store, or execute a command.

Human-readable mode may present progress and concise diagnostics on stderr.

Every Ghidra/adapter invocation captures diagnostics into a private temporary log. An ordinary successful invocation deletes that capture. Failure, timeout, forced termination, protocol violation, or a successful warning that explicitly references diagnostics causes the CLI to atomically retain it as a Diagnostic Log.

Sample-backed Diagnostic Logs remain until cleanup of that Sample or clean --all. Logs from non-Sample commands such as doctor remain until clean --all. Version 0.1 performs no automatic log expiry. Store directories use mode 0700 and log files use mode 0600.

The CLI never intentionally records Sample bytes, the caller's source Path, the full environment, or secrets from environment variables. However, Ghidra may echo symbols, strings, or other Sample-derived content. Diagnostic Logs are therefore sensitive rather than claimed to be fully redacted. Every retained log descriptor includes "sensitive": true, its tagged absolute Path, exact file-byte count, SHA-256, and media type.

A retained Diagnostic Log is an immutable directory bundle:

diagnostics/<id>/
├── manifest.json
├── stdout.log
└── stderr.log

manifest.json is schema-versioned JSON containing invocation metadata, termination reason, timestamps, stream byte counts, truncation flags, encodings, and SHA-256 digests. stdout.log and stderr.log contain the exact raw child-process bytes; the manifest states whether each stream is valid UTF-8 or arbitrary bytes. Agents can query metadata with jq and search ordinary text diagnostics directly with rg, tail, or sed without JSON escaping or Base64 overhead.

The entire bundle is published atomically by renaming its private temporary directory. Its descriptor points to the tagged absolute Path of manifest.json. Version 0.1 uses no SQLite diagnostic database; a future index may be built over immutable manifests without changing the bundle format.

The CLI drains child stdout and stderr concurrently so a full pipe cannot block Ghidra. Version 0.1 applies a fixed 8 MiB capture limit independently to each stream. A stream within the limit is retained completely as stdout.log or stderr.log. For a larger stream, the bundle retains its first 4 MiB and last 4 MiB as separate segment files and inserts no synthetic bytes into either.

The manifest records total observed bytes, captured bytes, truncated: true, and each segment's original byte offset, length, tagged relative Path, and SHA-256. Diagnostic truncation does not turn an otherwise successful operation into failure. Any retained result referencing that bundle emits the diagnostics_truncated warning. Version 0.1 exposes no diagnostic-size flag; real usage must justify adding one.

Every warning has exactly three fields:

{
  "code": "unverified_target",
  "message": "target is outside the verified integration-test matrix",
  "details": {
    "verified_targets": [
      "elf-x86_64-little-endian",
      "pe32plus-x86_64"
    ]
  }
}

code is a stable machine identifier. message is concise human context and must not be parsed for control flow. details is always an object with a warning-code-specific schema and is {} when no structured context applies. Warnings have no severity or retryable field because every member of the collection is already non-fatal. A warning never disguises incomplete required data; that condition is an error.

The encoder deduplicates warnings that have the same code and canonicalized details, then sorts the collection in bytewise ascending code order followed by canonicalized-details order. Warning collections remain subject to the normal serialized-byte bound.

Schema compatibility

Reviewed JSON Schema documents are published under schemas/v1/. Every response carries "schema_version": 1 and validates against its corresponding schema.

Within schema v1:

  • New optional fields are compatible.
  • Consumers must ignore unknown fields.
  • Existing fields cannot be removed.
  • Field types and meanings cannot change.

Removing a field or changing its type or meaning requires a new major schema version. The CLI emits only its current schema in the first release; maintaining multiple output encoders is deferred until real compatibility demand justifies it. Golden fixtures and schema-validation tests gate every response change. This policy is recorded in ADR 0001.

Functional requirements

Sample identity

  • Compute a SHA-256 digest from the Sample bytes.
  • Open the caller's path read-only and record source metadata before copying.
  • Reject regular Samples larger than 1,073,741,824 bytes before copying.
  • Allow --max-sample-bytes <BYTES> to replace that limit with a positive integer.
  • Reject zero and unlimited sentinel values for maximum Sample size.
  • Accept regular files and symlinks whose opened target is a regular file.
  • Accept non-UTF-8 Linux Sample paths because Ghidra receives only the tool-generated private snapshot path.
  • Reject directories, FIFOs, sockets, and device files.
  • Enforce maximum Sample size while streaming even when initial file metadata reports a smaller size.
  • Before copying, require available Analysis Store filesystem space of at least the observed Sample size plus 1,073,741,824 reserve bytes.
  • If later Analysis exhausts disk space, fail the Analysis, preserve diagnostics where possible, and remove staging.
  • Copy bytes into a private staging snapshot while computing SHA-256.
  • Record source metadata again after copying and fail with sample_changed if it changed.
  • Pass only the staged snapshot to Ghidra; Java never reads the caller's path.
  • Remove the snapshot after successful import or any failure.
  • Treat identical bytes as the same Sample regardless of path.
  • Never write to the Sample.
  • Record the observed path as provenance, not identity.

The digest describes the exact staged bytes used for Analysis. This boundary is recorded in ADR 0005.

Analysis compatibility

An Analysis is reusable only when its Sample digest and Analysis Profile match. At minimum, the Analysis Profile includes:

  • Ghidra version.
  • Java version.
  • Analysis-adapter version.
  • Ghidra language and compiler specification.
  • Fully resolved loader options.
  • Enabled analyzer configuration.
  • Maximum analysis CPU count.

The canonical Profile document has this version 0.1 shape:

{
  "profile_version": 1,
  "ghidra_version": "12.1.2",
  "java_version": "21",
  "analysis_adapter_version": 1,
  "target": {
    "loader": "<loader-id>",
    "processor_language": "<language-id>",
    "compiler_specification": "<compiler-spec-id>"
  },
  "loader_options": [],
  "analyzer_options": [],
  "max_cpu": 2
}

Option collections contain fully resolved typed entries rather than only user overrides. Entries sort bytewise by canonical option name and reject duplicate names. Canonical JSON uses UTF-8, lexicographically ordered object keys, no insignificant whitespace, decimal integers, no floating-point values, and no trailing LF. Strings remain byte-exact UTF-8 without Unicode normalization. The Analysis Profile digest is SHA-256 over those exact canonical bytes, and its schema is published under schemas/v1/.

Sample digest, source Path, Analysis Store Path, timestamps, inline and adapter response limits, Java heap limit, and timeouts do not affect Profile identity. Target Verification is also excluded because changing the project's integration-test coverage does not change Ghidra's Analysis. max_cpu is included conservatively because parallel analysis may influence ordering or tie resolution. The Sample digest and Profile digest together identify an Analysis.

An incompatible Analysis is preserved until normal retention policy removes it; it is not overwritten in place.

Version 0.1 uses one analyzer policy: Ghidra 12.1.2's default auto-analysis options. The Java adapter records the fully resolved option set in canonical form, and its digest is part of the Analysis Profile. Users cannot enable, disable, or tune individual analyzers in version 0.1.

Analysis execution

  • Invoke the official Ghidra headless launcher as a child process.
  • Pass requests to a small, bundled Java adapter.
  • Exchange structured request and response files rather than scrape logs.
  • Capture Ghidra logs separately for diagnostics.
  • Build new Analyses in a staging location.
  • Promote a staged Analysis atomically only after full successful analysis and adapter validation.
  • Treat Ghidra's native partial results after timeout as unusable.
  • On timeout, interruption, or analysis failure, preserve the Diagnostic Log and remove the partial staging project.
  • Pass a 600-second native analysis timeout to Ghidra by default.
  • Allow --analysis-timeout-seconds <SECONDS> to replace that default with a positive integer.
  • Reject zero and unlimited sentinel values for analysis timeout.
  • Apply a hard child-process watchdog equal to the sum of enabled native phase timeouts plus 120 seconds.
  • Give a cached metadata Query with no separately bounded native phase a 120-second child-process watchdog.
  • Derive the watchdog automatically; do not expose a competing total-timeout flag.
  • Terminate the child process after each command in the first release.
  • Report whether termination was graceful or forced.

Bubblewrap/Ghidra starts in its own process group. On watchdog timeout, the harness marks the invocation timed out, sends SIGTERM to the entire group, continues draining diagnostics for a fixed 10-second grace period, then sends SIGKILL and reaps the group if any process remains. A first user interrupt uses the same sequence but retains exit status 130; a second interrupt during the grace period escalates immediately to SIGKILL.

Bubblewrap uses parent-death handling so a crashed harness cannot leave the worker running. Once cancellation begins, no response or Analysis data can be promoted even if the worker races to report success. The harness retains diagnostics, removes staging, and reports termination as graceful or forced. Watchdog timeout exits 124 and user interruption exits 130. The grace period is not configurable in version 0.1.

The harness performs one worker attempt per invocation. It does not hide additional attempts behind transport fallback, timeout escalation, or automatic retry. A caller may explicitly repeat a failed read-only command when its typed error is retryable.

Each adapter invocation uses a private directory containing request.json, response.json.tmp, and response.json. Rust atomically writes mode-0600 request.json before launching Ghidra. The request contains protocol_version: 1, a random 128-bit invocation ID, an operation enum, validated tool-owned Paths, selected limits, and operation-specific arguments. Only the generated request and response Paths are passed as adapter arguments; Sample selectors and other untrusted values remain inside JSON.

Version 0.1 limits serialized request.json to 1,048,576 bytes (1 MiB). Rust enforces the ceiling before writing the request or launching Ghidra. Java independently bounds the bytes read from the request Path and rejects an oversized request before JSON parsing. An oversized request reaching Java indicates a harness defect or tampering and is reported by Rust as protocol_violation; the limit is internal and has no CLI override. Sample bytes never appear in request.json.

The Java adapter strictly rejects unknown or missing protocol fields because this is an internal boundary whose two sides ship together. It writes response.json.tmp, flushes it, and atomically renames it to response.json only after serialization completes. The response echoes the protocol version, invocation ID, and operation.

Rust accepts only the final regular response file and validates all three echoed values plus the operation-specific schema. A missing, malformed, mismatched, or merely temporary response is a protocol_violation; child output remains diagnostic data and is never parsed as a fallback result. The private invocation directory is removed after response validation or after its Diagnostic Log has been retained.

Version 0.1 limits a serialized adapter response to 268,435,456 bytes (256 MiB). Java enforces the ceiling while serializing, and Rust independently rejects an oversized final response. On overflow, Java discards the partial temporary response and returns a small result_too_large protocol response containing limit_bytes, observed_at_least_bytes, and the operation kind.

No partial Query data or Artifact is published. The completed Analysis remains reusable because only the Query failed. result_too_large is non-retryable with identical arguments, although a narrower collection page may succeed. Version 0.1 exposes no response-size override flag; observed usage must justify raising or configuring the limit.

Decompilation execution

  • Apply a 60-second Ghidra decompiler timeout per selected Function by default.
  • Allow --decompile-timeout-seconds <SECONDS> to replace that default with a positive integer.
  • Reject zero and unlimited sentinel values for decompilation timeout.
  • Include the selected decompiler timeout in the derived child-process watchdog.

JVM memory

  • Retain Ghidra headless's default 2,048 MiB maximum Java heap.
  • Allow --max-heap-mib <MIB> to set a positive integer heap limit.
  • Describe this value as a Java heap limit, not a total process-memory or RSS limit.
  • Treat an out-of-memory failure as an Analysis or Query failure, preserve its Diagnostic Log, and never promote staged Analysis data.

CPU usage

  • Pass -max-cpu 2 to Ghidra by default.
  • Allow --max-cpu <COUNT> to set a positive integer core limit.
  • Reject zero and negative values rather than inheriting Ghidra's implicit coercion behavior.
  • Record the selected core limit in response provenance.

Analysis Store

  • Use this version 0.1 layout:

    <store>/
    ├── store.json
    ├── analyses/<sample-sha256>/<profile-sha256>/
    │   ├── manifest.json
    │   └── project/
    │       ├── analysis.gpr
    │       └── analysis.rep/
    ├── artifacts/<sample-sha256>/<profile-sha256>/<artifact-sha256>.json
    ├── diagnostics/
    │   ├── <sample-sha256>/<invocation-id>/
    │   └── global/<invocation-id>/
    ├── quarantine/<sample-sha256>/<profile-sha256>/<invocation-id>/
    ├── locks/<sample-sha256>/<profile-sha256>.lock
    ├── staging/<invocation-id>/
    └── cleanup/<transaction-id>/
    
  • Use only lowercase ASCII labels, digests, and random IDs for tool-generated path components; no generated component starts with ..

  • Record the store-layout version in store.json.

  • Give every complete Analysis one manifest.json and one fixed Ghidra project named analysis.

  • Promote the whole Analysis directory from staging through a same-filesystem atomic rename.

  • Move the whole invalid Analysis directory into its unique quarantine path.

  • Name Artifacts by the SHA-256 of their exact file bytes.

  • Scope Diagnostic Log bundles to their Sample digest when available and to global otherwise.

  • Keep locks outside Analysis directories so promotion and quarantine cannot move an active lock.

  • Reject symlinks anywhere inside tool-owned storage as corruption.

  • Treat directory traversal as authoritative; version 0.1 has no SQLite or global mutable index.

  • Record these fields in every Analysis manifest.json:

    {
      "manifest_version": 1,
      "state": "complete",
      "sample": {
        "sha256": "<digest>",
        "size_bytes": 123456
      },
      "analysis_profile": {
        "sha256": "<digest>",
        "ghidra_version": "12.1.2",
        "adapter_protocol_version": 1,
        "target": {},
        "analyzer_options_sha256": "<digest>"
      },
      "created_by": {
        "ghidr_version": "0.1.0",
        "invocation_id": "<id>",
        "completed_at": "<RFC3339 UTC>"
      },
      "project": {
        "name": "analysis",
        "path": "project",
        "files": [
          {
            "path": "analysis.gpr",
            "size_bytes": 1234,
            "sha256": "<digest>"
          }
        ]
      }
    }
    
  • Inventory project files in bytewise relative-Path order.

  • Reject symlinks, special files, missing files, unexpected files, and recorded size or SHA-256 mismatches.

  • Recompute Sample and Analysis Profile digests and require them to match their directory names.

  • Hash the complete project tree before reusing an Analysis.

  • Open cached Queries through Ghidra's read-only mode.

  • Hash the complete tree again before accepting the Query result, proving the read-only invocation did not mutate it.

  • Treat any validation mismatch as corruption and apply the accepted quarantine and single-rebuild policy.

  • Prefer this correctness-first double validation in version 0.1; optimize it only after measurement demonstrates material cost.

  • Resolve the store through this fixed precedence:

    1. --store <ABSOLUTE_PATH>.
    2. $GHIDR_STORE.
    3. $XDG_CACHE_HOME/ghidr when XDG_CACHE_HOME is explicitly set and the resulting path passes Ghidra path validation.
    4. ~/ghidr-store.
  • Fail when an explicitly configured store path is invalid.

  • Require a UTF-8 Analysis Store path because it crosses the Java/Ghidra boundary. Reject a non-UTF-8 resolved store with invalid_store_path; typed error details retain its exact bytes using the tagged Path representation.

  • Never select the working directory automatically.

  • Report the resolved path and its source as cli, environment, xdg, or home_fallback through doctor and response provenance.

  • Keep the store local to one machine in the first release; synchronized or shared Ghidra project storage is unsupported.

  • Use atomic writes for manifests, Artifacts, and Diagnostic Logs.

  • Store oversized Query results as complete, content-digested Artifacts.

  • Detect incomplete Analysis creation after interruption.

  • Never expose or reuse an incomplete Analysis.

  • Acquire an OS-backed per-Analysis lock before staging.

  • Fail immediately with retryable analysis_busy when another process holds the same Analysis lock; include the Analysis Profile digest and a suggested retry delay.

  • Never start duplicate Ghidra work for the same Sample and Analysis Profile.

  • Allow concurrent reads of a completed immutable Analysis.

  • Fail cleanup with analysis_busy rather than remove targeted data that is in use.

  • Atomically move a structurally corrupt cached Analysis into quarantine.

  • Never use a Quarantined Analysis to satisfy a Query.

  • Attempt one fresh rebuild after quarantining corrupt data.

  • Return a corrupt_analysis_rebuilt warning when that rebuild succeeds and a structured failure when it does not.

  • Preserve quarantined manifests and Diagnostic Logs until explicit cleanup.

  • Retain Analyses and Artifacts until an explicit cleanup request.

  • Never apply automatic age- or size-based eviction in the first release.

  • Treat the entire store as disposable.

Output behavior

  • Default to JSON for terminals, pipes, and non-interactive execution.
  • Support explicit --format json and --format human values.
  • Never select a format through terminal detection.
  • Include schema version and provenance in every JSON response.
  • Bound collection responses and state the applied bound.
  • Apply the selected serialized-byte budget to every successful response and the fixed 65,536-byte safety cap to every error response.
  • Never omit undisclosed results from a successful response.
  • Never truncate a JSON document or field to satisfy the byte budget.
  • Spill oversized results atomically and return a bounded Artifact descriptor.
  • Support --limit and --offset for bounded collection pages.
  • Require --all for unbounded collection output.
  • Reject ambiguous or conflicting collection flags.
  • Document a stable order for every pageable Query.
  • Use hexadecimal addresses with an explicit address space where necessary.
  • Represent every JSON Address as an object containing space and offset.
  • Preserve the exact case-sensitive UTF-8 name returned by Ghidra in Address.space; do not case-fold or Unicode-normalize it.
  • Compare and order address-space names by their exact UTF-8 bytes.
  • Require --address SPACE:0x... to match an address-space name exactly.
  • Return address_space_not_found with bounded valid candidates for an unknown space, allowing an emitted Address to round-trip into a selector unchanged.
  • Encode an Address offset as a lowercase, 0x-prefixed hexadecimal string padded to the Address-space width; never encode it as a JSON number.
  • Keep JSON composable with standard tools such as jq.
  • Keep JSON-mode stdout and stderr free of all content outside their single success or error document.
  • Suppress progress in JSON mode and retain full diagnostics as Diagnostic Logs.
  • Provide concise human-readable presentation only when explicitly requested.

Errors and exit status

Distinct error categories must cover:

  • Invalid arguments.
  • Missing or unreadable Sample.
  • Unsupported or ambiguous format/language.
  • Missing or incompatible Ghidra/JDK installation.
  • Analysis timeout.
  • Ghidra analysis failure.
  • Function not found or function selector ambiguous.
  • Invalid or incompatible cached Analysis.
  • Required destructive confirmation not supplied.
  • Internal protocol violation.

In the default JSON format, every failure emits no stdout and emits one versioned error document on stderr:

{
  "schema_version": 1,
  "kind": "error",
  "error": {
    "code": "function_selector_ambiguous",
    "message": "symbol 'parse' resolves to more than one function",
    "retryable": false,
    "details": {
      "candidates": []
    }
  }
}

code is a stable machine identifier. message is concise human context and must not be parsed for control flow. retryable states whether repeating the same logical request may reasonably succeed without changing its arguments. details has an error-code-specific schema and remains subject to collection and serialized-byte bounds.

Argument-parsing failures follow the same contract when the caller requested JSON. Ambiguous Function Selector resolution is a failure with bounded candidates in details, never a partial success.

Exit statuses are deliberately coarse:

  • 0: success.
  • 1: runtime failure.
  • 2: invalid invocation or arguments.
  • 124: timeout.
  • 130: interrupted by the user.

The versioned JSON error.code provides precise diagnosis within those broad classes. This allocation is recorded in ADR 0002.

Architecture boundary

Rust CLI responsibilities

  • Argument parsing and validation.
  • Sample hashing and stable identity.
  • Analysis Profile construction.
  • Analysis Store layout, locking, and atomicity.
  • Child-process lifecycle, timeouts, and log capture.
  • Typed request and response schemas.
  • Published JSON Schema documents and compatibility checks.
  • Human and JSON rendering.
  • Exit-code policy.

Rust owns the authoritative compile-time operation registry. Each operation definition includes its request and response types, public command kind, published schema name, stable category, and one-sentence agent description. The category and description are internal metadata in version 0.1, retained so a future adapter can generate capability discovery without duplicating command definitions. Runtime reflection and dynamic operation registration are out of scope.

Java adapter responsibilities

  • Use supported Ghidra APIs.
  • Import and analyze a Sample.
  • Resolve Function Selectors.
  • Extract program and function metadata.
  • Invoke the Ghidra decompiler.
  • Serialize only the response defined by the protocol.

The Java adapter must not listen on a network socket, download dependencies, or execute user-provided scripts.

A conformance test compares the authoritative Rust operation registry with the Java dispatch set, published schemas, and golden fixtures. It fails when Rust declares an operation Java cannot handle, Java accepts an undeclared operation, or an operation lacks its required schema or golden coverage.

Rust implementation policy

Version 0.1 starts as one library-plus-binary Cargo package rather than a multi-crate workspace:

src/
├── main.rs
├── lib.rs
├── cli.rs
├── commands/
├── domain/
├── output/
├── process/
├── protocol/
├── sandbox/
└── store/

Supporting source lives under java/, schemas/v1/, tests/, and fixtures/. Domain and protocol types belong in the Rust library so integration tests exercise the same implementation as the executable.

The initial dependency set is deliberately small:

  • clap for typed CLI parsing.
  • serde and serde_json for protocol and public JSON.
  • schemars for schemas generated from the same Rust types.
  • sha2, hex, and base64 for identities and byte representations.
  • thiserror for typed internal errors.
  • rustix for Linux locks, process groups, signals, and filesystem metadata.
  • getrandom for 128-bit invocation and transaction IDs.
  • A small RFC3339 time implementation selected during scaffolding.
  • Test-only assert_cmd, tempfile, and jsonschema.

Version 0.1 has no async runtime, plugin framework, database, general logging framework, or multi-crate workspace. The repository commits Cargo.lock, pins Rust through Nix, and checks dependency licenses, advisories, and sources with cargo-deny. New dependencies require concrete behavior, compatible permissive licensing, and review of their transitive trees.

Synchronous execution decision

The first release uses a synchronous command lifecycle because each invocation coordinates one Ghidra child process and returns one result. Ghidra performs the CPU-intensive work; an asynchronous Rust runtime would not make that work faster.

The design must not prevent a future asynchronous frontend. A server, parallel batch analysis, interactive session, or responsive cancellation channel would constitute a concrete reason to add an async runtime.

Security and safety

  • Treat every Sample and every Ghidra-produced value as untrusted input.
  • Do not execute the Sample.
  • Do not provide arbitrary script execution.
  • Do not require network access during analysis.
  • Do not emit telemetry or send Sample-derived data, metadata, or diagnostics to a remote destination.
  • Avoid including Sample bytes or extracted secrets in routine logs.
  • Validate all paths crossing the Rust/Java boundary.
  • Apply bounded output, memory, and execution-time policies.
  • Document the supported Ghidra version and relevant security advisories.

The trusted Rust harness remains outside the narrow Ghidra worker sandbox. It owns Sample snapshotting, locks, limits, validation, the Analysis Store, and atomic promotion. The sandboxed Ghidra/JVM child receives only:

  • The tool-generated staged Sample as read-only.
  • The current project or staging directory with the minimum required access.
  • The private request/response invocation directory.
  • The read-only pinned Nix closure.
  • A private home and temporary directory.

It receives no caller source Path, broader workspace, unrelated Analysis data, or network access. The harness validates all worker output before promotion. This boundary is recorded in ADR 0006.

Version 0.1 supports three explicit sandbox modes:

  • --sandbox bubblewrap is the default. Bubblewrap is a separately maintained dependency, not part of Ghidra, and is included in the pinned Nix package. It isolates mount, network, PID, IPC, and UTS namespaces and uses parent-death handling.
  • --sandbox external declares that the calling agent/tool harness already provides equivalent isolation. ghidr cannot verify that policy, so provenance reports verification: unverified and emits external_sandbox_unverified.
  • --sandbox off is an explicit debugging escape hatch. Provenance reports verification: disabled and emits sandbox_disabled.

The CLI never auto-detects or silently changes sandbox mode. doctor verifies an actual Java/Ghidra probe for the Bubblewrap backend. External providers integrate through the surrounding harness in version 0.1; the CLI does not accept an arbitrary sandbox-runner command. A provider-neutral internal backend boundary permits reviewed native integrations later. Sandbox policy is response provenance, not Analysis Profile identity.

Performance expectations

Correctness and reproducibility take priority over startup latency in the first release.

  • A compatible cached Analysis should avoid repeating auto-analysis.
  • Metadata Queries should reuse exported Artifacts where correctness permits.
  • Targeted decompilation may start a new JVM.
  • The CLI should explain whether it reused or created an Analysis when verbose diagnostics are enabled.

Persistent workers and interactive sessions are deferred until measured usage shows JVM startup to be the dominant usability problem.

Test strategy

  • Run cargo fmt --check.
  • Run cargo clippy --all-targets --all-features -- -D warnings.
  • Run cargo test --all-targets.
  • Run cargo deny check.
  • Make nix flake check the complete local and Forgejo CI entry point.
  • Forbid Rust unsafe in version 0.1.
  • Deny unwrap, expect, panic, todo, and unimplemented in production code while permitting narrowly scoped use in tests.
  • Select useful strict Clippy lints explicitly rather than enabling every pedantic lint.
  • Compile Java with applicable lint warnings enabled and treated as errors.
  • Apply a deterministic Java formatter.
  • Format-check Nix and run statix.
  • Unit-test Sample identity, profile identity, store layout, schema validation, selector parsing, and error mapping without Ghidra.
  • Validate every structured response against its published JSON Schema.
  • Validate structured errors and their typed details against published schemas.
  • Verify the Rust operation registry, Java dispatch set, published schemas, and golden fixtures contain exactly the same operations.
  • Verify Rust rejects a request larger than 1 MiB before worker launch and Java independently rejects one before JSON parsing.
  • Test process lifecycle against a controllable fake child process.
  • Run a purpose-built probe through the production Bubblewrap launcher and prove it cannot reach a test listener outside the worker network namespace.
  • Regress against default data egress: the shipped CLI, environment-variable surface, and Java protocol expose no remote-destination or telemetry setting.
  • Maintain tiny, source-controlled fixture programs with known properties.
  • Compile ELF x86-64 and PE32+ x86-64 fixtures reproducibly.
  • Run integration tests against pinned Ghidra 12.1.2 and JDK 21.
  • Assert verified status for the ELF and PE fixtures and unverified status for at least one recognized target outside the matrix.
  • Run the complete real-Ghidra integration suite before accepting a Ghidra or JDK upgrade.
  • Compare JSON against reviewed golden fixtures after normalizing unstable values.
  • Never rewrite a golden fixture in CI; updates require an explicit local command and reviewed diff.
  • Add a regression test for every corrected bug.
  • Verify interruption leaves no Analysis that can be mistaken for complete.
  • Verify Ghidra timeout results are never promoted or reused.
  • Verify corruption causes quarantine and at most one automatic rebuild.
  • Verify identical bytes at different paths reuse one compatible Analysis.
  • Verify changed bytes at the same path do not reuse the old Analysis.
  • Verify a source change during snapshot creation aborts before Ghidra starts.
  • Verify Ghidra receives the staged snapshot path rather than the caller path.

Delivery milestones

Milestone 0: design

  • Review this PRD and the domain language.
  • Use ghidr as the short executable name.
  • Use the Apache-2.0 license.
  • Pin a nixpkgs revision providing Ghidra 12.1.2 and JDK 21.
  • Define the remaining pinned Nix inputs and package closure.
  • Define protocol schemas and use the accepted coarse exit-status allocation.
  • Publish the initial schemas/v1/ documents.

Milestone 1: vertical inspection slice

  • Create the Rust crate and minimal Java adapter.
  • Implement doctor.
  • Implement explicit Analysis Store cleanup.
  • Implement inspect.
  • Prove Rust to analyzeHeadless to Java to JSON end to end.

Milestone 2: function discovery

  • Implement functions.
  • Define stable address and Function Selector representations.
  • Add reproducible native fixtures.

Milestone 3: targeted decompilation

  • Implement decompile.
  • Add ambiguity handling and decompiler time limits.
  • Stabilize the first public JSON schema.

Milestone 4: packaging

  • Provide a reproducible x86-64 Linux Nix development and test environment.
  • Publish a Nix flake package usable through nix run.
  • Document installation and sandboxed use.
  • Publish the first pre-release.

Post-version-0.1: agent tracing

After the reliable inspection-to-decompilation slice is established, add separate bounded Queries for relationships and targeted detail in this priority order:

  1. strings: a stably ordered page containing exact Address, byte length, explicit encoding, decoded representation, and bounded reference links.
  2. xrefs: structured source and destination targets, reference type, operand index, direction, and containing source Function when present.
  3. calls: flat caller, callsite, and resolved or unresolved callee edges.
  4. disassemble: instructions for one explicit, bounded Address range or selected Function.
  5. function: signatures, parameters, locals, body ranges, thunk resolution, and alternate symbols for one selected Function.

These relationships do not belong in every functions item. Exact names and entry Addresses provide the stable identifiers that later Queries compose with, while separate schemas can represent relationship-specific ambiguity and provenance. Every Query retains explicit bounds, stable ordering, typed per-record fields, and the public byte-spill contract. Version 0.1's rejection of zero or implicit unlimited sentinels continues to apply. Whole-program graph renderers, recursive graph algorithms, prose records, and a custom filter language remain out of scope; downstream tools can transform bounded facts.

Batch Query execution is considered only after measurements show that repeated JVM startup materially dominates agent workflows. A batch design must define maximum item and aggregate byte counts, deterministic order, per-item typed success or error, cancellation, and explicit fail-fast versus continue behavior. It must not introduce hidden parallelism.

After the CLI schemas stabilize, an MCP integration may be built as a separate thin adapter. It invokes ghidr as a process, exposes at most one tool per stable Query, and forwards the CLI's versioned JSON unchanged. It may generate capability descriptions and categories from the Rust operation metadata. It must not call Ghidra directly, own Analysis Store policy, add a second result contract, depend on mutable current-Program state, or introduce a persistent listener into the core package. Stdio remains the default MCP transport; any future network transport requires a separate threat model.

Release acceptance criteria

The first release is acceptable when:

  • A new user can run doctor, inspect, functions, and decompile without manually creating a Ghidra project.
  • A user can inspect store usage and explicitly remove tool-owned data.
  • Repeating a Query against an unchanged Sample reuses compatible analysis.
  • All commands provide versioned JSON and documented exit behavior.
  • An interrupted analysis cannot be mistaken for a complete one.
  • A timed-out Analysis attempt cannot satisfy a later Query.
  • Tests exercise a real pinned Ghidra release and known fixture binaries.
  • No first-release command mutates the Sample or Analysis.
  • No analysis command requires a network connection.

Open decisions

  • No product-level decision currently blocks implementation.