Module Development
A module is a self-contained unit of offensive capability — a survey that gathers facts, an exploit that acts on a target, or a payload provider that builds something to deliver. Modules are not compiled into the daemon. They run as separate processes that Hovel launches and drives over a small, language-neutral JSON-RPC protocol on stdin/stdout.
That boundary is the whole point of this guide set: because the contract is a wire protocol rather than a Go interface, a module can be written in any language. Hovel ships SDKs and worked examples for Python, Go, and Rust, all implementing the exact same three example modules so you can compare them side by side.
For node-operation providers, listening posts, routed tasks, credential delivery/stamping, and using a Mesh with an otherwise ordinary module, see Developing Mesh Providers. For a decision-first map across every module and provider shape, start at Build a Module or Provider.
Module types
Every module declares one type. The daemon uses it to group modules and to reason about post-exploitation sessions.
survey— reconnaissance. Gathers facts about a target without changing it.exploit— an offensive action that may open an interactive session.payload_provider— generates payloads for delivery by other modules.
Payload-provider modules use the same launch, handshake, schema, logging, and session boundaries as survey and exploit modules. They may also expose generic step JSON-RPC methods for describing contracts, preparing local values, executing confirmed payload lifecycle steps, connecting sessions, and cleanup. The Squatter design records the first core provider contract.
Payload providers and installer modules do not write Hovel state directly. When
they install a target-side payload that should be tracked, they return an
explicit installed-payload descriptor. Hovel core validates it, assigns the
operator-facing handle, persists it in SQLite, and later passes the provider's
reconnect descriptor back for payloads connect.
Start ordinary; add provider contracts only when needed
A module that surveys or exploits one target, even over TLS and even with
explicit target credentials, normally implements only info,
schema, and run. Do not advertise Mesh or workspace-PKI
delivery merely because a protocol is encrypted or authenticated.
| Need | Contract | Current boundary |
|---|---|---|
| Normal target operation | Base module lifecycle | No credential-provider or Mesh contract |
| Provider-owned nodes, tasks, routed streams, or listening posts | mesh.describe plus only supported optional Mesh methods | Implemented in Go, Python, and Rust |
| Consume assignment-bound workspace certificates or keys | credential.describe plus the exact runtime/files hook consumed | Direct runtime selections are currently initiated for Mesh listener, task, stream, and bridge calls |
| Provider-native encoding or artifact/deployment stamping | credential.encode or credential.stamp with matching advertised schemas and targets | Typed contracts and dispatch exist; the external daemon API currently exposes bookkeeping list/inspect, not production initiation |
| Reuse an existing host-and-port client through a Mesh | OpenMeshBridge plus an authenticated SDK bridge helper | Local adapters support TCP streams and UDP datagrams; other protocols remain provider-owned tasks or sessions |
Target-created CredentialCapability evidence and daemon-owned
workspace PKI are different domains. Read
target credential evidence,
workspace PKI custody,
TLS and Workspace PKI, and the
credential-provider guide
before adding a credential descriptor. Automatic workspace-PKI injection into
an ordinary non-Mesh execute call is not implemented.
How a module runs
The daemon owns the lifecycle. It spawns the module process, speaks JSON-RPC to it, and tears it down — except when the module opens a session, in which case the process is kept alive so the operator can interact with it.
- handshake
- schema
- execute
- sessions
- shutdown
The conversation is always the same sequence of methods:
handshake— the module reports its name, version, type, and tags.schema— the module reports the configuration it requires.execute— the daemon runs the module against one target and collects findings, artifacts, outputs, and any sessions.session/*— if the module opened a session, the operator reads and writes it through the daemon.shutdown— the daemon asks the module to exit cleanly.
The full message-by-message contract — framing, every method, and every data shape — is documented in the Wire Protocol reference. You rarely write it by hand: the SDKs implement the protocol and let you focus on the module's behavior.
Choosing a language
All three SDKs expose the same base concepts under language-appropriate names:
a module object/trait with info, schema, and
run; a context for inputs, logging, and sessions; and a result
carrying findings and artifacts. All three dispatch optional Mesh and
credential-delivery hooks. Go and Python dispatch step.*; Rust does
not. Pick the narrowest implemented surface your tooling and target environment
favor.
Implementing one module in all three SDKs
To keep a Python, Go, and Rust implementation equivalent, define the module
contract first, then port the same contract across SDKs. The contract is the
tuple Hovel sees over RPC: handshake metadata, schema
requirements, execute inputs and result fields, optional sessions,
and optional installed-payload descriptors.
- Choose a stable module name, version, type, summary, tags, chain config, target config, and result shape. Keep these values identical except for language suffixes when you intentionally publish side-by-side examples such as
mock-survey,mock-survey-go, andmock-survey-rust. - Write the Python package with
HovelModule,module.py,__main__.py, and a unit/contract test usinghovel_sdk.testing.ModuleRPC. Package it with a launcher that uses Hovel-managed Python, an operator interpreter, or a bundled interpreter. - Write the Go binary with a type implementing
hovel.Module, amainthat callshovel.Serve, and tests for both directRunbehavior and framed RPC when protocol behavior matters. Package the built binary as one or more host-platform launch entries. - Write the Rust binary with a type implementing
hovel::Module, amainthat callshovel::serve, and tests using normal Rust unit tests orserve_withfor framed RPC coverage. Package the built binary as one or more host-platform launch entries. - If the module lives in this repository as an example, add or update BUILD metadata, add new test targets to the root
test_suite, and extend the module packaging task so release builds publish a.tgz. - If the module is private or out-of-tree, use
hovel module install --link /absolute/pathduring development and package a.tgzfor sharing.
The existing triads are the reference implementation pattern:
modules/examples/python/mock_survey, modules/examples/go/mock_survey,
and modules/examples/rust/mock_survey for surveys; the matching
mock_exploit and mock_exploit_session directories for
findings, artifacts, failures, and interactive sessions.
The developer contract
If you are bringing an exploit, survey, or post-exploitation tool into Hovel, this is the practical contract you are signing:
- Stdout belongs to the protocol. Never print progress, banners, debug output, or child-process output to stdout. The daemon reads stdout as framed JSON-RPC. Use the SDK logger for operator-visible logs and stderr only for crash diagnostics.
- Metadata paths must be boring. Catalog loading calls
handshake,schema, optionalstep.describe, optionalmesh.describe, and optionalcredential.describe. Those paths must be fast, deterministic, offline, and side-effect free: no target contact, listener startup, random prepared values, payload generation, credential resolution or creation, protected-file access, or filesystem mutation. - Configuration is explicit. Declare every operator-controlled value as a
Requirement. Do not read arbitrary environment variables or hidden config files for target behavior unless the value is also surfaced in the schema or returned evidence. - Use Hovel's log rail. Logs should be structured fields, not prose blobs. Log target host, stage, protocol, handle, and provider ids as fields so transcripts are searchable.
- Return durable state deliberately. Facts belong in
outputs, human/audit observations infindings, blobs inartifacts, live handles insessions, and target-side payload inventory only in explicitinstalledPayloadsdescriptors. - Dangerous modules self-identify. If your module can crash a host, write disk, create accounts, install payloads, bind listeners, execute commands, or maintain access, tag it
dangerousand add the confirmation requirements the operator must satisfy. - Agent context is optional. Modules may ignore agent-aware execution entirely. When no agent context exists, SDKs expose no agent object and module behavior must be identical to non-MCP execution. Modules that opt in can read the SDK agent context and return
agentHints; hints are untrusted module-authored context and must never bypass Hovel guardrails.
Packaging a module
A published module is a .tgz archive with a required
hovel-module.yaml manifest. The manifest describes package identity,
trusted install scripts, and how Hovel launches the module on the operator's
host. Runtime schema still comes from handshake, schema,
optional step.describe, optional mesh.describe, and
optional credential.describe; the package manifest is install and
launch metadata, not the authoritative module behavior schema.
my-module-0.1.0.tgz
hovel-module.yaml
bin/
linux-amd64/my-module
windows-amd64/my-module.exe
python/
hovel_my_module/
requirements.txt
vendor/
wheels/
scripts/
setup.py
README.md
LICENSE
apiVersion: hovel.dev/v1alpha1
kind: ModulePackage
metadata:
name: my-module
version: 0.1.0
moduleType: exploit
summary: Example module.
tags: [lab]
author: Example Labs
license: Apache-2.0
runtime:
protocol: jsonrpc-stdio
launch:
- selector:
os: linux
arch: amd64
command: ["bin/linux-amd64/my-module"]
- selector:
os: windows
arch: amd64
command: ["bin/windows-amd64/my-module.exe"]
- selector: {}
python:
managed:
versions: ["3.10-3.14"]
requirements: "python/requirements.txt"
wheelhouse: "vendor/wheels"
command: ["{python}", "-m", "hovel_my_module"]
scripts:
postInstall:
command: ["{python}", "scripts/setup.py"]
One package can contain any number of platform variants. Selectors describe the host running Hovel, not the target. Hovel chooses the most specific matching launch entry and fails on ties. If no launch entry matches, install can still succeed, but execution reports that the package has no launcher for the current host.
Install scripts run by default. That is intentional: modules are trusted code
and already get arbitrary code execution when the operator runs them. Use
--no-scripts only for recovery or debugging. Script commands are
argv arrays, run from the package root, and receive
HOVEL_MODULE_ROOT, HOVEL_INSTALL_ROOT,
HOVEL_WORKSPACE, and HOVEL_OFFLINE.
Python packaging choices
Python modules choose one of three interpreter strategies in the package manifest.
# Hovel-managed python-build-standalone.
python:
managed:
versions: ["3.10-3.14"]
requirements: "python/requirements.txt"
wheelhouse: "vendor/wheels"
command: ["{python}", "-m", "hovel_my_module"]
# Operator-supplied interpreter.
python:
interpreter: "/opt/python/bin/python3"
command: ["{python}", "-m", "hovel_my_module"]
# Bundled interpreter.
interpreters:
python:
path: "runtime/python/bin/python3"
Managed Python uses a Hovel-pinned python-build-standalone release, downloads
the host-platform runtime only during explicit install when network use is
allowed, and creates one virtual environment per installed module version.
Dependencies are declared with requirements.txt and an optional
wheelhouse path; Hovel does not invent a second Python dependency language.
First integration loop
A hostile-but-productive integration loop should fit in one terminal. Start from the smallest module that tells the daemon who it is, what config it needs, and one target-safe thing it did.
# 1. Copy the nearest example in your language.
# Python: modules/examples/python/mock_exploit/
# Go: modules/examples/go/mock_exploit/
# Rust: modules/examples/rust/mock_exploit/
# 2. Link or install the package for the active workspace.
hovel module install --link "$PWD/path/to/my-module-package-root"
# 3. Let Hovel validate package shape and runtime metadata without executing.
task start
hovel> module installed
hovel> module check <your-module-id>
hovel> module inspect <your-module-id>
# 4. Run the strongest wired slice check available before calling the integration done.
task check
For a private module, use --link while iterating and install a built .tgz before sharing the chain or workspace with another operator.
Validate with module check
hovel module check is the module author's fast CI check. It validates
module shape and implementation metadata without running execute,
starting a throw, touching a target, preparing payloads, or contacting the
internet. Success exits 0. Any failure exits 1. Warnings
exit 0 unless the caller passes --warnings-as-errors.
hovel module check ms17-010-exploit@v1.0.0
hovel module check --all
hovel module check --warnings-as-errors ./dist/modules/my-module-0.1.0.tgz
hovel run --workspace .hovel -- module check mock-survey
Installed module references run the non-executing JSON-RPC discovery path:
handshake, schema, step.describe, optional
mesh.describe and credential.describe, then
shutdown. The report then shows the validated runtime protocol,
parsed metadata, chain and target config schema counts, provider step contract
count, and any advertised Mesh or credential-delivery contract.
MODULE CHECK ms17-010-exploit@v1.0.0
status ✅ PASS
module ms17-010-exploit@v1.0.0
CHECK NAME MESSAGE
----- ---- -------
✅ PASS rpc discovery handshake, schema, optional provider discovery, and shutdown completed
✅ PASS runtime jsonrpc-stdio
✅ PASS metadata ms17-010-exploit@v1.0.0 exploit v1.0.0
✅ PASS config schema 1 chain requirements, 3 target requirements
✅ PASS step contracts 0 provider steps
Package roots also validate hovel-module.yaml, host launcher
selection, launcher command availability, and then run the same runtime
discovery when the selected launcher is already runnable. Package archives are
checked for manifest shape and host-launch selection only; archive checks do not
extract the package or run module code, so runtime discovery is reported as a
warning. Managed-Python packages whose interpreter has not been installed also
warn instead of downloading anything.
SDK support matrix
| Capability | Python SDK | Go SDK | Rust SDK |
|---|---|---|---|
| survey/exploit lifecycle | HovelModule.run |
Module.Run |
Module::run |
| structured logs | standard logging |
ctx.Log |
ctx.info/ctx.error |
| interactive sessions | LineShellSession or custom Session |
LineShellSession, PTYSession, or custom Session |
LineShellSession or custom Session |
generic step.* provider hooks |
override describe_steps, prepare_step, execute_step, cleanup_step |
implement StepProvider |
not implemented yet; use Python or Go for provider/chain-step modules |
| credential-provider discovery and execution | describe_credential_delivery, load_runtime_credential, load_credential_files, encode_credential_material, stamp_credential |
CredentialDescriber plus only the runtime/files/encoding/stamp interfaces implemented by the module |
Module::describe_credential_delivery plus optional runtime/files/encoding/stamp methods |
| authenticated local Mesh bridge client | MeshBridgeEndpoint, MeshBridgeNetwork, connect_mesh_bridge |
NewMeshBridgeEndpoint, DialMeshBridge |
MeshBridgeCapability, MeshBridgeEndpoint, connect_mesh_bridge_tcp/connect_mesh_bridge_udp |
| payload-provider helper types | installed-payload result helpers | PayloadProvider, step, capability, and installed-payload types |
installed-payload result helpers; provider dispatch not implemented yet |
| framed RPC contract tests | hovel_sdk.testing.ModuleRPC |
sdk/go/hoveltest.NewRPCConn |
serve_with with in-memory streams |
Exploit and post-exploitation boundaries
Keep ownership boundaries sharp. An exploit should report access it actually established, evidence that proves it, and any capabilities later steps may consume. It should not smuggle provider-specific payload internals through generic outputs.
- A module that opens an operator shell returns a
SessionRef; the daemon keeps the module process alive and brokerssession/read,session/write, andsession/close. - A module that installs or observes a durable target-side payload returns an
InstalledPayloadDescriptor. Hovel core validates and persists that descriptor; the module does not write workspace state directly. - A payload provider owns its protocol, reconnect descriptor, cleanup descriptor, file-transfer semantics, task ids, and retry behavior. Hovel owns planning, confirmation, artifact recording, installed-payload inventory, and session brokering.
step.preparegenerates prepared values that are forwarded intostep.executeby the current capability-step runtime. The target review flow is to show and optionally edit those values before confirmation, then fail noisily after confirmation if name collisions or target-side surprises would change the footprint.
Troubleshooting modules
- Catalog load fails before your code runs: check
hovel-module.yaml, install records, selected launch entry, relative paths, executable bits, and Python package names. Managed Python failures are install-time problems; runtime should not be resolving interpreters or dependencies. - Handshake or schema times out: remove target probes, imports with network side effects, expensive payload builds, and listener startup from metadata paths.
- Malformed frame or JSON error: something wrote to stdout outside the SDK. Redirect subprocess stdout to a file/artifact, stderr, or a session stream.
- Operator cannot see progress: use structured SDK logging. In Python, use
ctx.logor the standardloggingpackage afterservestarts; in Go and Rust, use the context logger methods. - Session opens and then dies: keep the session object alive through the SDK session registry, return a successful result with the session id, and let the daemon send
shutdownlater. Do not exit the process yourself after opening a session.
Installing a module
Operators install modules from local packages, absolute package paths, HTTPS URLs, cached packages, configured package roots, index-backed package references, bulk manifests, or linked package roots.
hovel module install ./my-module-0.1.0.tgz
hovel module install https://github.com/org/repo/releases/download/v0.3.2/my-module-0.1.0.tgz --sha256 ...
hovel module install my-module
hovel module install my-module@0.1.0
hovel module bulk-install https://github.com/org/repo/releases/download/v0.3.2/module-install-set.yaml
hovel module bulk-install ./lab-modules.yaml
hovel module install --link /home/me/dev/my-module
Bare install references choose the newest semver package from the first matching local source tier: download cache, configured package roots and archives, then the source-tree module index when present. Hovel consults configured indexes after local sources. Bulk-install manifests may be local YAML files or HTTPS URLs. The default install target is the active workspace. --global installs into user-level storage. Workspace modules take precedence over global modules and cached packages.
The command launcher is what makes Hovel polyglot: any executable that
speaks the stdio JSON-RPC protocol is a module, regardless of how it was built.
Building and running
As with everything in this repo, go through Task. The SDK and module slices are outside the core Bazel workspace, but the root Task dispatcher already wires their integration builds, tests, CI gates, and release packaging. Link editable module package roots while iterating.
# Validate SDKs and example modules from a full source checkout
task sdk:ci
task modules:examples:ci
task modules:ci
# Build release module packages and indexes when you need installable archives
task release:modules-package
# Install or link module packages for the active workspace
hovel module install --link "$PWD/path/to/module-package-root"
# Launch the interactive CLI
task start
# In the CLI, list installed modules for the workspace
hovel> module installed
task check validates every slice present in a partial checkout;
task ci requires a full checkout and runs the wired remote-compatible
gate. Use task start or task daemon for the Hovel process,
then invoke module operations through the CLI so build and runtime assumptions
stay aligned with CI.
Safety
Hovel is an authorized-security-testing tool, and modules inherit that posture. Two rules matter while you develop:
- A module that performs destructive or otherwise dangerous actions must advertise the
dangeroustag. The daemon refuses to throw a dangerous module unless the operator passes--allow-dangerous. - A throw cannot start without a persisted plan and a recorded confirmation. The example modules never touch a real target — they exist to exercise orchestration safely.
See Safety and Scope for the full trust model.