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

1633 lines
64 KiB
Markdown

# 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:
```console
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:
```json
{
"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
```console
$ 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:
```json
{
"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
```console
$ 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:
```json
{
"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
```console
$ 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:
```json
{
"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:
```console
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:
```json
{
"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:
```json
{
"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:
```json
{
"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
```console
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:
```json
{
"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
```console
$ ghidr --format human decompile ./sample --name main
int main(int argc, char **argv)
{
...
}
```
Exactly one explicit Function Selector is required:
```console
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
```console
$ ghidr inspect ./sample
```
JSON is the unconditional default. Human-readable output is explicit:
```console
$ ghidr --format human inspect ./sample
```
JSON uses a versioned envelope:
```json
{
"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:
```json
{
"encoding": "utf8",
"value": "/tmp/sample"
}
```
A Linux path containing non-UTF-8 bytes uses standard padded Base64 over its
exact Unix byte sequence:
```json
{
"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:
```text
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:
```json
{
"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](./docs/adr/0001-version-the-json-contract.md).
## 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](./docs/adr/0005-analyze-a-staged-sample-snapshot.md).
### 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:
```json
{
"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:
```text
<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`:
```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:
```json
{
"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](./docs/adr/0002-keep-exit-statuses-coarse.md).
## 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:
```text
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](./docs/adr/0006-sandbox-the-ghidra-worker.md).
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.