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

Rust Modules

A Rust module compiles to a single binary that the daemon launches by path. The SDK lives at sdk/rust/hovel and is dependency-free: it hand-rolls a tiny JSON layer and base64 codec rather than pulling in serde_json, so the whole crate builds with nothing but the standard library. Real modules are free to add whatever crates they like. The worked examples are under modules/examples/rust/. The generated source reference is published at Rust SDK API.

Rust's standard library already defines Result, so this SDK names the module return type Outcome. It is the exact equivalent of the Python/Go Result.

The Module trait

pub trait Module {
    fn info(&self) -> Info;                       // → handshake
    fn schema(&self) -> Schema;                   // → schema
    fn run(&self, ctx: &mut Context) -> Outcome;  // → execute
}

fn main() { hovel::serve(MyModule); }

info and schema are catalog-inspection paths. Keep them fast, deterministic, offline, and side-effect free. Put target I/O, payload building, random target-side names, and listener startup in run or in a Go/Python step provider, not in Rust module metadata.

Stdout is reserved for the SDK's framed JSON-RPC stream. Use ctx.info and ctx.error for structured logs, artifacts for captured output, and sessions for interactive streams. Use stderr only for process-level diagnostics.

Current SDK boundary

The Rust SDK currently implements the base module lifecycle, structured logs, artifacts, interactive sessions, installed-payload result descriptors, all optional Mesh methods, all five typed credential-provider methods, and authenticated TCP/UDP Mesh bridge clients. It does not dispatch the generic step.* methods or the full payload-provider RPC surface available in Go.

Use Rust today for standalone survey or exploit modules where a compiled, dependency-free SDK binary is valuable, including Mesh and credential-aware providers and modules that return installed-payload descriptors from run. Use Go for payload-provider discovery, generation, reconnect, and cleanup RPC. Use Go or Python for capability-native step.* providers.

TLS, workspace PKI, and Mesh bridges

A normal Rust module can use its chosen TLS crate inside run without overriding credential methods. Override them only when the module consumes or transforms assignment-bound workspace-PKI material. The base execute path does not automatically receive a resolved PKI bundle.

Provider needModule method
Describe strict slots and capabilitiesdescribe_credential_delivery
Consume in-memory bytes or a scoped referenceload_runtime_credential
Consume invocation-scoped protected filesload_credential_files
Produce one bounded provider-native encodingencode_credential_material
Stamp an artifact or provider-owned deploymentstamp_credential

Override only the methods advertised by CredentialDeliveryDescriptor; every other default returns an explicit unsupported error. Secret wrappers redact Debug, but accessors still expose values to provider code. Keep them scoped to the call and out of logs, errors, outcomes, and durable config. The current external path initiates direct runtime certificate DER, public-key SPKI, and private-key PKCS #8 delivery before Mesh operations. General non-Mesh delivery and external encoding/stamping initiation remain extension points. See TLS and Workspace PKI, Credential Provider Development, and the generated Rust SDK API.

Connect to an authenticated Mesh bridge

use hovel::{
    connect_mesh_bridge, MeshBridgeConnection, MeshBridgeEndpoint,
};
use std::time::Duration;

let endpoint = MeshBridgeEndpoint::from_response(
    bridge.local_host,
    u16::try_from(bridge.local_port)?,
    &bridge.local_network,
    bridge.capability,
)?;
match connect_mesh_bridge(&endpoint, Some(Duration::from_secs(5)))? {
    MeshBridgeConnection::Tcp(stream) => use_tcp(stream)?,
    MeshBridgeConnection::Udp(socket) => use_udp(socket)?,
}

from_response strictly parses the response's localNetwork, not the provider-defined request.protocol. The generic connector preserves the concrete TCP or UDP socket in an enum; specialized helpers are also available and reject a mismatched endpoint. All helpers validate a canonical numeric loopback address and send the ephemeral bearer capability before application data. Never log or persist it. Use a provider task or session when the protocol cannot preserve TCP stream or UDP datagram semantics. The complete decision map is Build a Module or Provider.

A survey module

use hovel::json::Value;
use hovel::{Context, Info, Module, ModuleType, Outcome, Requirement, Schema};

struct MockSurvey;

impl Module for MockSurvey {
    fn info(&self) -> Info {
        Info {
            name: "mock-survey-rust".into(),
            version: "v0.0.0-example".into(),
            module_type: ModuleType::Survey,
            summary: "Collect example target facts.".into(),
            description: String::new(),
            tags: vec!["example".into(), "survey".into(), "rust".into()],
            discovery_context: Vec::new(),
        }
    }

    fn schema(&self) -> Schema {
        Schema {
            target_config: vec![
                Requirement::new("target.host", "host", "Target host name or IP address."),
                Requirement::new("target.port", "port", "Target TCP port."),
            ],
            ..Schema::default()
        }
    }

    fn run(&self, ctx: &mut Context) -> Outcome {
        let host = ctx.input_str("target.host", &ctx.target);
        let port = ctx.input_str("target.port", "unknown");
        ctx.info("connecting to target",
            &[("host", Value::from(host.as_str())), ("port", Value::from(port.as_str()))]);
        let facts = Value::object(vec![
            ("host", Value::from(host.as_str())),
            ("port", Value::from(port.as_str())),
            ("reachable", Value::from(true)),
        ]);
        Outcome::ok(vec![("facts".into(), facts)])
            .with_summary(&format!("example survey reached {host}:{port}"))
    }
}

fn main() { hovel::serve(MockSurvey); }

ctx.input_str(key, default) resolves a config value as a string. ctx.info(message, fields) emits a module/log notification; fields are (&str, Value) pairs built with the Value::from conversions.

Findings and artifacts

The Outcome builder chains with with_* methods.

fn run(&self, ctx: &mut Context) -> Outcome {
    if ctx.input_str("failure_mode", "") == "execution" {
        return Outcome::failed("example exploit failed during execution");
    }
    Outcome::ok(vec![("target".into(), Value::from(ctx.target.as_str()))])
        .with_summary("example exploit completed without target interaction")
        .with_finding(Finding::new(
            "example exploit path verified", "info",
            "exercised orchestration without touching a real target",
        ))
        .with_artifact(Artifact::inline(
            "mock-exploit-rust-transcript.txt", "text/plain",
            "example module invoked through daemon RPC",
        ))
}

Prefer Outcome::failed for expected operator-facing failures such as "not vulnerable" or "missing confirmation". Reserve panics and process exits for bugs or unrecoverable local failures; otherwise the daemon sees a broken module rather than a useful exploit result.

Installed payload records

A Rust exploit that installs or observes a durable target-side payload can return an explicit InstalledPayloadDescriptor. Hovel validates the descriptor, assigns the operator-facing handle, and persists it as installed payload inventory. The Rust module still does not own workspace writes.

use hovel::{InstalledPayloadDescriptor, PayloadProviderRecord};

let payload = InstalledPayloadDescriptor::new(
    "squatter",
    "squatter/windows/x86/xp-sp3/tcp-bind/pe-exe",
    ctx.target.as_str(),
)
.with_payload_version("0.1.0")
.with_transport("tcp-bind")
.with_endpoint("10.0.0.42:9101")
.with_supports_reconnect(true)
.with_reconnect(
    PayloadProviderRecord::new(
        "squatter.reconnect.tcp_bind",
        vec![
            ("host".into(), Value::from("10.0.0.42")),
            ("port".into(), Value::from(9101_i64)),
        ],
    )
    .with_schema_version("1"),
)
.with_cleanup(PayloadProviderRecord::new(
    "squatter.cleanup.tcp_bind",
    vec![("remotePath".into(), Value::from(r"C:\Windows\Temp\n4x9q2.exe"))],
));

Outcome::ok(vec![("target".into(), Value::from(ctx.target.as_str()))])
    .with_summary("payload installed and recorded")
    .with_installed_payload(payload)

Interactive sessions

LineShellSession::new takes a prompt, an echo flag, and a command handler closure. ctx.open_session registers it; the session outlives run.

fn run(&self, ctx: &mut Context) -> Outcome {
    let target = ctx.target.clone();
    let shell = LineShellSession::new("mock$ ", true, move |command| match command {
        "whoami" => "mock-operator".to_string(),
        other => format!("mock-shell: command not found: {other}"),
    });
    let sref = match ctx.open_session(
        Box::new(shell),
        SessionOptions::default()
            .with_name(&format!("mock shell on {target}"))
            .with_capabilities(&["read", "write", "exec", "close"]),
    ) {
        Ok(sref) => sref,
        Err(err) => return Outcome::failed(&format!("failed to open session: {err}")),
    };
    Outcome::ok(vec![("sessionId".into(), Value::from(sref.id.as_str()))])
        .with_summary("mock exploit opened an interactive shell session")
}

Test without the daemon

Keep pure module behavior testable through ordinary Rust tests. For protocol coverage, use the SDK's serve_with helper with an in-memory reader and writer; that exercises the same Content-Length framing the daemon uses.

#[test]
fn info_is_a_rust_exploit() {
    let info = MockExploit.info();
    assert_eq!(info.name, "mock-exploit-rust");
    assert_eq!(info.module_type, ModuleType::Exploit);
}

The Rust SDK and in-repo Rust examples now live outside the core Bazel workspace. Validate them through task sdk:test, task sdk:ci, task modules:examples:test, and task modules:examples:ci; the Task-backed targets remain hermetic.

For real exploit work, keep parsers, packet builders, state machines, and result construction behind testable functions. Reserve live target execution for an authorized lab; CI should stay offline and deterministic.

Build, package, and run

Rust examples are part of the modules/ slice after the partial-checkout split. Use task modules:examples:ci for the language examples, task modules:ci for the full module slice, and task release:modules-package for installable archives. These remain separate from the core-only task build gate while task ci composes them in a full checkout.

rust_binary(
    name = "mock-survey-rust",
    srcs = ["src/main.rs"],
    edition = "2021",
    deps = ["//sdk/rust/hovel"],
)
apiVersion: hovel.dev/v1alpha1
kind: ModulePackage
metadata:
  name: mock-survey-rust
  version: 0.1.0
  moduleType: survey
runtime:
  protocol: jsonrpc-stdio
launch:
  - selector:
      os: linux
      arch: amd64
    command: ["bin/linux-amd64/mock-survey-rust"]
hovel module install --link "$PWD/modules/examples/rust/mock_survey"
task start
hovel> module installed   # ... mock-survey-rust, mock-exploit-rust, mock-exploit-session-rust

That completes the trio: the same three modules in Python, Go, and Rust, all speaking the one wire protocol.