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

Go Modules

A Go module compiles to a single static binary that the daemon launches by path. The SDK is dependency-light and lives at sdk/go/hovel (github.com/vibepwners/hovel/sdk/go/hovel); the worked examples are under modules/examples/go/. The shapes mirror the Python SDK one-to-one. Generated references are published for hovel and hoveltest.

The Module interface

type Module interface {
    Info() Info               // → handshake
    Schema() Schema           // → schema
    Run(ctx *Context) (Result, error)  // → execute
}

func main() { hovel.Serve(MyModule{}) }

Serve runs the JSON-RPC loop on stdin/stdout. Info and Schema must be cheap and side-effect free — the daemon calls them while building its catalog.

Do not write to stdout yourself. The SDK serializes responses and notifications there; banners, fmt.Println, inherited subprocess stdout, or progress bars will corrupt the framed stream. Use ctx.Log, artifacts, or sessions for operator-visible output. Stderr is reserved for process-level diagnostics that help explain startup or crash failures.

TLS, workspace PKI, and Mesh bridges

An ordinary Go module can use crypto/tls inside Run without implementing any Hovel credential interface. Add the credential-provider contracts only when the module itself consumes or transforms assignment-bound workspace-PKI material. Hovel does not automatically inject a PKI bundle into a normal non-Mesh Run call.

Provider needGo surface
Describe strict slots and capabilitiesCredentialDescriber.DescribeCredentialDelivery
Consume in-memory bytes or a scoped referenceCredentialRuntimeProvider.LoadRuntimeCredential
Consume invocation-scoped protected filesCredentialFilesProvider.LoadCredentialFiles
Produce one bounded provider-native encodingCredentialEncodingProvider.EncodeCredentialMaterial
Stamp an artifact or provider-owned deploymentCredentialStampProvider.StampCredential
var (
    _ hovel.CredentialDescriber       = (*TLSProvider)(nil)
    _ hovel.CredentialRuntimeProvider = (*TLSProvider)(nil)
)

Implement only the interfaces advertised by the descriptor. If a Mesh descriptor also embeds CredentialDelivery, it must match the standalone descriptor exactly. The daemon can currently initiate direct runtime certificate DER, public-key SPKI, and private-key PKCS #8 delivery immediately before a Mesh listener, task, stream, or bridge in the same process. General non-Mesh delivery and external encoding/stamping initiation remain extension points. See TLS and Workspace PKI, Credential Provider Development, and the generated Go SDK API.

Dial an authenticated Mesh bridge

A consumer that already speaks TCP or UDP needs no Mesh-provider interface. Receive localHost, localPort, localNetwork, and the ephemeral capability from OpenMeshBridge, then let the SDK consume the authentication preface before protocol bytes are sent. Use the returned local network; do not infer it from the provider-defined request.protocol.

func dialBridge(
    ctx context.Context,
    host string,
    port int,
    network hovel.MeshBridgeNetwork,
    capability string,
) (net.Conn, error) {
    endpoint, err := hovel.NewMeshBridgeEndpoint(
        host,
        port,
        network,
        capability,
    )
    if err != nil {
        return nil, err
    }
    return hovel.DialMeshBridge(ctx, endpoint)
}

Convert the response's localNetwork to hovel.MeshBridgeNetwork. The validated endpoint retains it, so callers cannot accidentally dial a UDP bridge as TCP or vice versa; construction rejects values other than MeshBridgeNetworkTCP and MeshBridgeNetworkUDP. Keep the capability in memory and never log, persist, or return it as module evidence. Use a provider-native task or session instead for protocols that cannot preserve TCP stream or UDP datagram semantics. The full decision flow starts at Build a Module or Provider.

A survey module

package main

import (
    "fmt"

    "github.com/vibepwners/hovel/sdk/go/hovel"
)

type MockSurvey struct{}

func (MockSurvey) Info() hovel.Info {
    return hovel.Info{
        Name:    "mock-survey-go",
        Version: "v0.0.0-example",
        Type:    hovel.TypeSurvey,
        Summary: "Collect example target facts.",
        Tags:    []string{"example", "survey", "go"},
    }
}

func (MockSurvey) Schema() hovel.Schema {
    return hovel.Schema{
        TargetConfig: []hovel.Requirement{
            hovel.Req("target.host", "host", "Target host name or IP address."),
            hovel.Req("target.port", "port", "Target TCP port."),
        },
    }
}

func (MockSurvey) Run(ctx *hovel.Context) (hovel.Result, error) {
    host := ctx.InputString("target.host", ctx.Target)
    port := ctx.InputString("target.port", "unknown")
    ctx.Log.Info("connecting to target", "host", host, "port", port)
    return hovel.Ok(
        map[string]any{"facts": map[string]any{"host": host, "port": port, "reachable": true}},
        hovel.WithSummary(fmt.Sprintf("example survey reached %s:%s", host, port)),
    ), nil
}

func main() { hovel.Serve(MockSurvey{}) }

ctx.InputString(key, default) resolves a config value as a string. ctx.Log emits structured records (key/value pairs, like log/slog) that reach the daemon as module/log notifications.

Findings and artifacts

Build the result with functional options.

func (MockExploit) Run(ctx *hovel.Context) (hovel.Result, error) {
    if ctx.InputString("failure_mode", "") == "execution" {
        return hovel.Failed("example exploit failed during execution"), nil
    }
    return hovel.Ok(
        map[string]any{"target": ctx.Target},
        hovel.WithSummary("example exploit completed without target interaction"),
        hovel.WithFindings(hovel.Finding{
            Title:    "example exploit path verified",
            Severity: "info",
            Detail:   "exercised orchestration without touching a real target",
        }),
        hovel.WithArtifacts(hovel.InlineArtifact(
            "mock-exploit-go-transcript.txt", "text/plain",
            "example module invoked through daemon RPC",
        )),
    ), nil
}

Installed payload records

A Go exploit or provider that installs a durable target-side payload should return an explicit descriptor with hovel.WithInstalledPayloads. Hovel validates and persists it; the module never writes installed payload inventory directly.

payload := hovel.InstalledPayloadDescriptor{
    Provider:          "squatter",
    PayloadID:         "squatter/windows/x86/xp-sp3/tcp-bind/pe-exe",
    PayloadVersion:    "0.1.0",
    Target:            ctx.Target,
    State:             "installed",
    Transport:         "tcp-bind",
    Endpoint:          "10.0.0.42:9101",
    SupportsReconnect: true,
    Cleanup: &hovel.PayloadProviderRecord{
        Schema:     "squatter.cleanup.tcp_bind",
        Descriptor: map[string]any{"remotePath": `C:\Windows\Temp\n4x9q2.exe`},
    },
}
return hovel.Ok(
    map[string]any{"payload": payload.PayloadID},
    hovel.WithSummary("payload installed and recorded"),
    hovel.WithInstalledPayloads(payload),
), nil

Interactive sessions

LineShellSession needs no subclassing in Go — set its Handle closure. ctx.OpenSession registers it and returns a ref; the SDK attaches the ref to the result automatically.

func (MockExploitSession) Run(ctx *hovel.Context) (hovel.Result, error) {
    shell := &hovel.LineShellSession{
        Prompt: "mock$ ",
        Echo:   true,
        Handle: func(command string) (string, error) {
            switch command {
            case "whoami":
                return "mock-operator", nil
            default:
                return "mock-shell: command not found: " + command, nil
            }
        },
    }
    ref, err := ctx.OpenSession(shell,
        hovel.WithName("mock shell on "+ctx.Target),
        hovel.WithCapabilities("read", "write", "exec", "close"),
    )
    if err != nil {
        return hovel.Result{}, err
    }
    return hovel.Ok(
        map[string]any{"sessionId": ref.ID},
        hovel.WithSummary("mock exploit opened an interactive shell session"),
    ), nil
}

For modules that already wrap a local interactive client, use PTYSession on supported platforms. It exposes a local pseudoterminal through the same Hovel session broker, so operator input and output still travel through session/write and session/read instead of raw stdout.

Step and payload providers

Go is the best-supported SDK for payload providers and capability-native post-exploitation steps. Implement StepProvider when the module should expose planner-visible steps; implement PayloadProvider when it also owns payload discovery, generation, reconnect, cleanup, and payload chunking.

type StepProvider interface {
    hovel.Module
    DescribeSteps() (hovel.StepContractSet, error)
    PrepareStep(hovel.StepPrepareRequest) (hovel.StepPrepareResult, error)
    ExecuteStep(hovel.StepExecuteRequest) (hovel.StepExecuteResult, error)
    CleanupStep(hovel.StepCleanupRequest) (hovel.StepCleanupResult, error)
}

DescribeSteps is called during catalog inspection and planning, so it must be pure. PrepareStep may materialize editable planned values such as usernames, passwords, service names, pipe names, or paths, but it must not touch the target. ExecuteStep consumes confirmed prepared values exactly and returns capabilities, evidence, sessions, state transitions, and installed payload descriptors. CleanupStep is retriable and should report explicit state transitions rather than hiding partial cleanup failures.

The sdk/go/hoveltest package has provider-oriented test helpers; use it for contract tests that assert payload listing, resolution, session connection, and cleanup behavior without starting a real daemon.

Test without the daemon

Start with ordinary Go unit tests against Info, Schema, and Run. Build a hovel.Context directly and assert result status, outputs, findings, artifacts, and installed payload descriptors.

func TestMockExploitFailureMode(t *testing.T) {
    ctx := &hovel.Context{
        Target: "mock://target",
        Inputs: map[string]any{"failure_mode": "execution"},
    }
    result, err := MockExploit{}.Run(ctx)
    if err != nil {
        t.Fatal(err)
    }
    if result.Status != "failed" {
        t.Fatalf("status = %q, want failed", result.Status)
    }
}

Use hoveltest.NewRPCConn when you need to test the actual framed JSON-RPC surface, including handshake, schema, step methods, payload-provider methods, and shutdown behavior.

The Go SDK and in-repo Go examples now live outside the core Bazel workspace. The root Task dispatcher still exposes them through task sdk:test, task sdk:ci, task modules:examples:test, and task modules:examples:ci.

Build, package, and run

Go modules build with Bazel (BUILD files are generated by Gazelle via task fmt). The release package carries the compiled binary and a hovel-module.yaml launch entry for each supported host platform.

go_binary(
    name = "mock_survey",
    embed = [":mock_survey_lib"],
)
apiVersion: hovel.dev/v1alpha1
kind: ModulePackage
metadata:
  name: mock-survey-go
  version: 0.1.0
  moduleType: survey
runtime:
  protocol: jsonrpc-stdio
launch:
  - selector:
      os: linux
      arch: amd64
    command: ["bin/linux-amd64/mock-survey-go"]
  - selector:
      os: darwin
      arch: arm64
    command: ["bin/darwin-arm64/mock-survey-go"]
hovel module install --link "$PWD/modules/examples/go/mock_survey"
task start
hovel> module installed   # ... mock-survey-go, mock-exploit-go, mock-exploit-session-go

Run task sdk:ci and task modules:examples:ci before submitting SDK or example changes. task modules:ci covers the complete module slice, and task release:modules-package builds the installable module archives and indexes. Add core test targets to core/BUILD.bazel and module tests to their slice-level suites.