Docs / Python SDK and artifact APIs

HydIR Python SDK (compatible v1/v2 plus additive v3)

Repository reference · sdk/python/README.md · source digest 6447efaa557d

LocalGhidra and HydirClient examples, allocated memory, call tracing, observed-path comparison, typed models, and remote artifacts.

Why allocated memory can still be unknown#

An allocation declaration answers whether an address belongs to a modeled range. The seed answers whether its bytes have known values. Declaring stack memory establishes an extent; it does not fill the region with zeroes.

Load validation before value constructionRequested LOADaddress and widthInside modeled range?ELF mapping or declared allocationAll required bytes known?missing bytes → explicit stopAssemble valuelittle-endian byte order
Conceptual load checks. An invalid range or missing byte stops the operation rather than fabricating a value.

Worked example: a two-byte load

Suppose the modeled range contains 0x700000 and 0x700001. The first byte is known as 0x41; the second is absent. A two-byte load cannot return 0x0041, because that would assume the missing byte is zero. Supplying 0x42 at the second address permits 0x4241.

Illustrative known-byte mask

address    initial value    known
0x700000   0x41             yes
0x700001   ??               no

LOAD stops: byte at 0x700001 is unknown.

This example explains the model; it does not execute an ELF. LLVM v4 includes checked process bytes and writable globals; v5 adds declared stack and heap ranges. Unknown relocations, undeclared addresses, and missing bytes remain source-linked boundaries.

Calls share machine state

trace_calls and llvm_cfg_calls share registers and modeled RAM across validated caller and callee snapshots. Function and depth budgets bound collection and execution. A concrete indirect target must identify a supported loaded callee. Unknown imports and missing callees stop explicitly.

Returns are checked against their modeled targets. Inspect the operation log and stop record before interpreting an emitted module as a complete function. Its fidelity stays unknown until the relevant path is compared with an execution oracle.

Implementation references: Concrete execution · Call and return handling

Local worker and snapshot artifacts#

For local Ghidra-backed lifting, LocalGhidra calls the same hydirctl worker as the desktop app. It needs no service credentials:

from hydir_sdk import LocalGhidra

client = LocalGhidra("hydirctl")
snapshot = client.analyze("sample.elf", "snapshot.json")
state = client.artifact("state", "sample.elf", "snapshot.json")

analyze(..., function=0x...) selects another function from the managed Ghidra project. artifact also accepts pcode, simplify, semantics, cfg, process-memory, llvm-prefix, and llvm-standalone; llvm_operation emits LLVM for one exact P-code operation. Artifacts are checked against the binary SHA-256, and a function-level state artifact is an effect inventory rather than an executable lift. llvm-standalone provides a runnable byte-state module for the supported straight-line prefix, with an explicit stop reason; it does not claim whole-function equivalence. import_project(binary, project_gpr, "folder/program.elf", snapshot, function=0x...) exports one program from a closed, analyst-edited Ghidra project. The CLI stages an isolated copy, checks that the selected program matches the original ELF, and runs fresh on each call so later analyst edits are visible. trace_prefix(binary, snapshot, seed, max_operations=4096) runs the bounded concrete interpreter against a binary-bound JSON seed and returns its trace. trace_path(...) follows a bounded path through selected instructions and returns source-linked branch events and its explicit stop reason. LocalGhidra.trace_calls(binary, seed, function=...) follows bounded direct calls and automatically collects callee snapshots. For an uploaded project, HydirClient.trace_ghidra_calls(project_id, revision, seed, function_entry=...) provides the same artifact through the authenticated v3 service. LocalGhidra.llvm_cfg_calls(binary, root_snapshot, (callee_snapshot,)) emits one bounded LLVM state machine across validated loaded callees. Its fidelity claim remains unknown until a concrete path is compared with an execution oracle.

Process bytes and declared allocations#

LocalGhidra.llvm_cfg(binary, snapshot, process=True) emits LLVM v4 with checked ELF process bytes, zero-filled tails, and writable globals. Its mapping and known-byte masks keep unresolved relocations explicit. For explicit stack or heap ranges, supply a small declaration file:

{"schema_version":1,"regions":[{"kind":"stack","space":"ram","base":7340032,"byte_len":4096}]}

LocalGhidra.llvm_cfg(binary, snapshot, allocations="allocations.json") emits LLVM v5. trace_path(..., memory="allocated", allocations="allocations.json") uses the same bounds in the Rust executor. Declaring a range does not initialize its bytes; put known input bytes in the seed. An access outside the ELF mappings and declared ranges stops explicitly. The service exposes the same v5 artifact through analyze_ghidra_binary(project_id, revision, "llvm-cfg-process-allocated", allocations="allocations.json").

Observed targets and path comparisons#

LocalGhidra.rediscover_calls(binary, snapshot, input_spec, trace) returns bounded, byte-verified candidate targets from one observed input while retaining unresolved static call edges. Pass apply=True to reanalyze an isolated copy of the managed Ghidra project; the original static snapshot remains available. compare_observed_path(binary, snapshot, input_spec, trace, seed, memory="process") executes a bounded P-code path from the supplied seed and compares it with one Frida trace; inconclusive does not mean the paths match. LocalGhidra.rediscover_jumps(...) uses the independent DynamicTrace v3 computed-jump witnesses for already analyzed instructions inside the selected function. Its plan and isolated apply=True route keep the unresolved branch visible alongside observed targets. The revision-checked service exposes the same read-only jump plan through HydirClient.analyze_ghidra_observation(..., "observed-jump-rediscovery").

Automatic bounded call lifts#

LocalGhidra.llvm_cfg_calls_auto(binary, seed, function=0x...) runs the managed worker and collects only callees reached by the seed before lifting. Add allocations="allocations.json" to trace_calls, llvm_cfg_calls, or llvm_cfg_calls_auto to share validated ELF process bytes and declared stack or heap ranges across those calls. The strict trace is v3 and the call CFG contains LLVM v5. The remote trace_ghidra_calls and build_ghidra_call_cfg_llvm methods accept the same declaration as bytes or a file path, bound to the uploaded ELF and project revision. Unknown imports, relocations, and undeclared memory still stop at their source instruction. HydirClient.build_ghidra_call_cfg_llvm(project_id, revision, seed, function_entry=...) runs that bounded lift for an uploaded ELF through the v3 service and returns a revision-bound LLVM artifact. The returned module exposes source operations and explicit stops; it is not a whole-program lift.

Remote projects and API negotiation#

This SDK is backed by the same protobuf schema as hydirctl remote and hydird. It supports authenticated loopback or TLS discovery, project creation, explicit ELF upload, inspect/CFG/lift/scalar-C, bounded global-effect analysis, durable lift jobs and event streams, a named allowlisted LLVM 14 pass experiment, restricted whole-executable rebuilding, and verified artifact download. There is no remote execution endpoint.

negotiate_api() prefers hydir.v3, then falls back through v2 to v1 only when the newer endpoint reports UNIMPLEMENTED. v3 exposes isolated native whole-program jobs, replayable events, revision-checked analyst facts, and digest/media/schema/revision-checked ProgramSpec, FunctionIndex, coverage, MachineIR, StateIR, FunctionIR, CIR, LLVM-export, and DecompilationUnit artifacts. get_program_artifact(...) never executes the input and requires a FunctionIndex selector for function-scoped stages.

Revisioned analysis models#

get_analysis_model(project_id, revision) returns the current validated AnalysisModel v1 as JSON. Edit that document and call save_analysis_model(project_id, revision, model, idempotency_key=...) with the project revision you read. The model's own revision must increase by one. The server checks the binary digest and layout, retains machine evidence, persists the edit, and returns the new project revision. Later typed C and high-level CIR artifact requests use the saved model. Reuse the same idempotency key when retrying an uncertain save.

Remote Ghidra artifact requests#

analyze_ghidra_snapshot(project_id, revision, snapshot, stage) sends an exported Ghidra snapshot as bytes or a file path to the v3 service. The project must already contain the same binary. The server checks the snapshot digest against that binary and runs a bounded worker. Supported stages are snapshot, pcode, simplify, semantics, state, cfg, coverage, process-memory, llvm-cfg, llvm-cfg-image, llvm-cfg-process, llvm-cfg-process-allocated, and slice. The simplify stage returns original and rewritten raw P-code with per-operation preconditions; its local bitvector identities do not claim native equivalence. For llvm-cfg, pass start_address=0x... to select a CFG entry. For slice, pass instruction_index and operation_index, plus optional input_index. The SDK checks the artifact hash, media type, schema version, and project revision before returning JSON. analyze_ghidra_binary(project_id, revision, "snapshot") asks the service to run its managed Ghidra worker on the already uploaded ELF, returning the validated function index and selected function snapshot. Pass selected_function_entry=0x... for another Ghidra function, then request pcode, state, llvm-cfg, or slice. The explicit automatic flag prevents an empty legacy snapshot request from starting analysis. For local snapshots, LocalGhidra.slice(binary, snapshot, instruction_index, operation_index, input_index=None) returns a bounded backward P-code value slice with source operations and explicit unresolved boundaries.

v2 methods expose digest-checked RegionSpec v3, PhysicalRegionIR v1, and DecompilationUnit v1 JSON, compile scalar patch v1 into reversible PatchBundle v2, apply it with revision/idempotency protection, and request structural bundle verification. Behavioral verification remains false until a separate execution gate supplies evidence.

lift_region(...) returns exact decoded operations, successors, physical register/flag/memory effects, boundary locations, and unresolved proof facts. Its lowering_ready field must be true before a caller treats it as patchable; successful decoding alone is not a C or replacement-safety claim.

Scalar C and transformation requests#

decompile(project_id, revision, symbol, assume_u64x2=True) returns C11 from the bounded scalar LLVM lift; it is not a general or Rellic-compatible decompiler. get_source() retrieves the exact source tar advertised by a matching-source build and verifies its SHA-256. Ordinary development builds do not embed such an archive, so get_source() then fails explicitly. apply_patch(...) accepts the versioned scalar patch v1 document and requires three explicit assertions. It commits a new project revision and returns hash-checked ELF bytes; it does not execute them. The examples/patch_scalar.py script demonstrates an explicit trusted-fixture upload and patch, with a new output path. transform(...) requires explicit trusted-fixture and prototype assertions. It creates a new immutable project revision retaining the same ELF bytes, and returns a report plus hash-checked raw/before/after LLVM IR artifacts; LLVM verification is not behavioral equivalence. examples/pass_experiment.py demonstrates the explicit upload, named pass request, and no-overwrite artifact export. examples/global_analysis.py saves the conservative cross-function report and a partial ProgramSpec with provenance-bearing call/reference sites through the typed Analyze and AnalyzeSpec operations. It needs two new, distinct output paths and does not claim complete control-flow recovery. examples/annotate_program.py explicitly uploads a trusted ELF, adds a revisioned, scoped analyst name/comment/assumption, checks its binary-bound ledger, and exports JSON to a new file. Analyst facts are unverified; assumptions appear in remote inspected/analyzed specifications but do not satisfy explicit prototype/trust flags for other operations. The SDK checks the returned project, revision, and binary identity. rebuild(...) requires an explicit trusted-fixture assertion. It creates a new immutable binary revision and returns digest-checked ELF/IR/report artifacts without executing the ELF. examples/rebuild_program.py shows explicit upload and new-file-only export. Use only the documented static freestanding Linux x86-64 subset; the service worker is not a hostile-input sandbox. examples/validate_program.py is an opt-in, client-side Docker comparison for trusted static ELFs. It requires --trusted-fixture, a JSON list of hex-encoded stdin cases, and a new report path. It compares stdout, stderr, and exit status under no-network, read-only, CPU/memory/process/time controls. This does not establish hostile-input isolation or general equivalence. A ready case list is ../../tests/fixtures/whole_choice_cases.json.

Remote observation jobs#

On a Linux x86-64 server with the separately bundled Frida observer, start_frida_observation(project_id, revision, input_spec, selected_elf_vaddr) queues an explicit operator-only job for the uploaded ELF. Use get_analysis_job or analysis_events to follow it, then pass the completed job's revision and artifact digest, the InputSpec binary digest, and the selected address to get_frida_observation. The SDK checks the artifact hash, revision, schema, binary, and address. DynamicTrace v2 records bounded path events and output; process exit code remains unknown.

Installation and smoke workflow#

Install Python 3.10+ in a private virtual environment:

python3 -m venv .venv
.venv/bin/python -m pip install .

For the runnable smoke example, first run an authenticated hydird service and create a private credential file as described in remote operation. Then supply a trusted x86-64 ELF and a symbol with the asserted u64(u64,u64) prototype:

.venv/bin/python examples/smoke.py http://127.0.0.1:50051 /private/token.txt /path/to/fixture.elf hydir_max2 /new/output/directory

On a Docker-equipped host, bash ../../scripts/demo-sdk-linux-docker.sh builds a pinned Linux SDK image and runs the separate-process smoke and rebuild examples. The scripts retain private fixture artifacts under target/demo-sdk/; do not publish credentials or that directory.

examples/batch_lift.py accepts only the symbols explicitly named on its command line. Passing a symbol asserts the same prototype for it; the SDK does not infer signatures. No sample is uploaded without a call to upload_binary. Artifact bytes are SHA-256 checked before being returned, and export_artifact refuses to overwrite an existing path. The client rejects plaintext non-loopback endpoints and non-private credential files. https://host:port endpoints use gRPC TLS with its default trust roots; callers using a private CA may pass its PEM bytes as root_certificates. Credential files may contain either a local 64-character static token or a bounded compact OIDC JWT; the server remains responsible for signature and claim validation.

Regenerating protobuf bindings#

Generated hydir*_pb2.py and hydir*_pb2_grpc.py correspond to the v1, v2, and v3 schemas in crates/hydir-api/proto/, produced with grpcio-tools==1.84.0. Regenerate after API changes with:

python -m grpc_tools.protoc -I../../crates/hydir-api/proto \
  --python_out=hydir_sdk --grpc_python_out=hydir_sdk \
  ../../crates/hydir-api/proto/hydir.proto \
  ../../crates/hydir-api/proto/hydir_v2.proto \
  ../../crates/hydir-api/proto/hydir_v3.proto

After regeneration, change generated sibling imports to relative imports (for example, from . import hydir_v2_pb2) so the package imports correctly.