HOVEL // chapter 17
Chapter 17 Part 04 / Module Development

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.

NeedContractCurrent boundary
Normal target operationBase module lifecycleNo credential-provider or Mesh contract
Provider-owned nodes, tasks, routed streams, or listening postsmesh.describe plus only supported optional Mesh methodsImplemented in Go, Python, and Rust
Consume assignment-bound workspace certificates or keyscredential.describe plus the exact runtime/files hook consumedDirect runtime selections are currently initiated for Mesh listener, task, stream, and bridge calls
Provider-native encoding or artifact/deployment stampingcredential.encode or credential.stamp with matching advertised schemas and targetsTyped 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 MeshOpenMeshBridge plus an authenticated SDK bridge helperLocal 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.

Module Runtime Topology
hovelddaemonowns lifecycle
Module ProcessPython / Go / Rustspeaks JSON-RPC
catalog, throw plan, transcript, session broker
  1. handshake
  2. schema
  3. execute
  4. sessions
  5. shutdown

The conversation is always the same sequence of methods:

  1. handshake — the module reports its name, version, type, and tags.
  2. schema — the module reports the configuration it requires.
  3. execute — the daemon runs the module against one target and collects findings, artifacts, outputs, and any sessions.
  4. session/* — if the module opened a session, the operator reads and writes it through the daemon.
  5. 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.

  1. 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, and mock-survey-rust.
  2. Write the Python package with HovelModule, module.py, __main__.py, and a unit/contract test using hovel_sdk.testing.ModuleRPC. Package it with a launcher that uses Hovel-managed Python, an operator interpreter, or a bundled interpreter.
  3. Write the Go binary with a type implementing hovel.Module, a main that calls hovel.Serve, and tests for both direct Run behavior and framed RPC when protocol behavior matters. Package the built binary as one or more host-platform launch entries.
  4. Write the Rust binary with a type implementing hovel::Module, a main that calls hovel::serve, and tests using normal Rust unit tests or serve_with for framed RPC coverage. Package the built binary as one or more host-platform launch entries.
  5. 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.
  6. If the module is private or out-of-tree, use hovel module install --link /absolute/path during development and package a .tgz for 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, optional step.describe, optional mesh.describe, and optional credential.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 in findings, blobs in artifacts, live handles in sessions, and target-side payload inventory only in explicit installedPayloads descriptors.
  • 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 dangerous and 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.

Direct terminal demo inspecting mock survey and exploit modules before running them through a configured chain.
Demo: module metadata to runnable chain

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 brokers session/read, session/write, and session/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.prepare generates prepared values that are forwarded into step.execute by 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.log or the standard logging package after serve starts; 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 shutdown later. 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 dangerous tag. 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.

Where to go next