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

Module Wire Protocol

This is the contract between the Hovel daemon and a module process. The SDKs implement it for you; you only need this page if you are writing an SDK, debugging a module by hand, or porting Hovel modules to a new language. The runtime is identified as jsonrpc-stdio.

Framing

Messages are JSON-RPC 2.0 objects, each prefixed with an HTTP-style Content-Length header. This framing (rather than newline-delimited JSON) lets session payloads carry arbitrary bytes without escaping games.

Content-Length: 58\r\n
\r\n
{"jsonrpc":"2.0","id":1,"method":"handshake","params":{}}
  • Header lines end with \r\n; a bare \r\n terminates the header block.
  • Header names are case-insensitive; only Content-Length is required.
  • The body is exactly Content-Length bytes of UTF-8 JSON.

Stdout is exclusively the framed protocol stream. Any unframed bytes on stdout — banners, debug prints, child-process output, progress bars — are a protocol error. Module authors should send operator-visible progress through module/log, session output through module/session plus session/read, and large captured output through artifacts. Stderr is captured as process diagnostics and may be attached to module startup, schema, execution, or shutdown failures.

Message kinds

Three shapes travel over the wire:

  • Request (daemon → module): has an integer id and a method. Expects a response.
  • Response (module → daemon): echoes the id with either result or error.
  • Notification (module → daemon): has a method but no id. No response. Used for logs and session events.
{"jsonrpc":"2.0","id":1,"result":{ ... }}
{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"boom"}}

The daemon reads only error.message; the code is advisory.

SDKs should return JSON-RPC -32600 for structurally invalid requests, -32601 or -32000 for unknown methods, and -32000 for module/runtime failures. Hovel treats any error response as a failed RPC call and records the message in the relevant catalog, plan, execute, session, provider, or cleanup path.

Methods (daemon → module)

This table is the complete module RPC method set. SDKs may implement only the subset their language surface supports, but unknown or unsupported methods must fail with a JSON-RPC error rather than silently succeeding.

Method Params Result Implemented by
handshake{}ModuleInfoPython, Go, Rust
schema{}ModuleSchemaPython, Go, Rust
executeExecuteRequestModuleResultPython, Go, Rust
session/writeSessionWriteRequest{ "status": "ok" }Python, Go, Rust
session/readSessionReadRequestSessionReadResultPython, Go, Rust
session/closeSessionCloseRequest{ "status": "ok" }Python, Go, Rust
shutdown{}{ "status": "ok" }Python, Go, Rust
step.describe{}StepContractSetPython, Go
step.prepareStepPrepareRequestStepPrepareResultPython, Go
step.executeStepExecuteRequestStepExecuteResultPython, Go
step.cleanupStepCleanupRequestStepCleanupResultPython, Go
credential.describe{}CredentialDeliveryDescriptorPython, Go, Rust
credential.runtimeCredentialRuntimeRequestCredentialDeliveryReceiptPython, Go, Rust
credential.filesCredentialFilesRequestCredentialDeliveryReceiptPython, Go, Rust
credential.encodeCredentialEncodingRequestCredentialEncodingResultPython, Go, Rust
credential.stampCredentialStampExecutionRequestCredentialStampExecutionResultPython, Go, Rust
list_payloadsPayloadQueryPayloadInfo[]Go
resolve_payloadPayloadQueryPayloadInfoGo
prepare_listenerPrepareListenerRequestListenerRefGo
generate_payloadGeneratePayloadRequestPayloadArtifactSetGo
connect_sessionConnectSessionRequestSessionRefGo
cleanup_payloadCleanupPayloadRequestCleanupResultGo
read_payload_chunkReadPayloadChunkRequestPayloadChunkGo
mesh.describeMeshDescribeRequestMeshDescriptorPython, Go, Rust
mesh.topologyMeshTopologyRequestMeshTopologyPython, Go, Rust
mesh.beaconsMeshBeaconRequest{ "beacons": MeshBeacon[] }Python, Go, Rust
mesh.listenersMeshListenerListRequest{ "listeners": MeshListener[] }Python, Go, Rust
mesh.listener.startMeshListenerStartRequestMeshListenerPython, Go, Rust
mesh.listener.stopMeshListenerStopRequestMeshListenerPython, Go, Rust
mesh.taskMeshTaskRequestMeshTaskResultPython, Go, Rust
mesh.open_streamMeshStreamRequestSessionRefPython, Go, Rust

handshake

Reports module identity. Called while building the catalog and again before each throw.

{
  "name": "mock-survey",
  "version": "v0.0.0-example",
  "moduleType": "survey",            // survey | exploit | payload_provider
  "summary": "Collect example target facts.",
  "description": "Example survey module ...",
  "tags": ["example", "survey", "python"]
}

schema

Reports the configuration the module requires, split into chain-level and target-level.

{
  "chainConfig":  [ Requirement, ... ],
  "targetConfig": [ Requirement, ... ],
  "outputs": {}
}

credential.describe

Reports the optional credential slots and delivery, encoding, or stamping capabilities a module actually implements. This is a catalog metadata call: it must not resolve an assignment, open protected files, export private material, contact a target, or mutate provider state. A module may expose this descriptor without exposing Mesh. When the same descriptor also appears inside MeshDescriptor.credentialDelivery, Hovel requires the standalone and nested canonical descriptors to have the same SHA-256 digest.

{
  "schemaVersion": "hovel.pki.credential-delivery/v1",
  "deliveryCapabilities": ["runtime", "files", "stamp-standard"],
  "credentialSlots": [{
    "name": "control-plane-mtls",
    "purpose": "mtls-server",
    "endpointRole": "server",
    "consumerType": "mesh-listener",
    "acceptedBundleVersions": ["hovel.pki.bundle/v1"],
    "acceptedProfiles": ["mtls-server"],
    "acceptedCompatibilityTargets": ["portable-x509"],
    "acceptedProjections": ["bundle"],
    "acceptedMaterialForms": ["private-bytes"],
    "maximumEncodedBytes": 16384,
    "remainderPolicy": "preserve",
    "privateMaterial": "allowed"
  }],
  "stampTargetKinds": ["named-slot"]
}

The capability list is not decorative. none is exclusive; runtime, files, stamp-standard, and stamp-advanced authorize only their matching requests. Provider target and encoding extensions are admitted only through advertised, versioned JSON schemas.

credential.runtime

Delivers one resolved projection directly to the provider process. The request is bound to an assignment, named slot, exact module/provider/version, and descriptor digest. Material is a closed union: public and private-byte forms carry data; private-reference carries a provider-scoped reference. Exactly one variant is allowed.

{
  "schemaVersion": "hovel.pki.provider-execution/v1",
  "provider": {
    "moduleId": "lab-mesh",
    "providerId": "lab-credential-loader",
    "providerVersion": "1",
    "descriptorSha256": "8f...64-hex-characters...2a"
  },
  "requestId": "credential-request-01",
  "assignmentId": "assignment-listener-west",
  "slotName": "control-plane-mtls",
  "credential": {
    "bundleVersion": "hovel.pki.bundle/v1",
    "purpose": "mtls-server",
    "consumerType": "mesh-listener",
    "profileId": "mtls-server",
    "compatibilityTargetId": "portable-x509"
  },
  "material": {
    "projection": "bundle",
    "form": "private-bytes",
    "encoding": "json+der-base64",
    "sha256": "6d...64-hex-characters...91",
    "data": "eyJzY2hlbWFWZXJzaW9uIjoiLi4uIn0="
  },
  "scope": {
    "operationId": "mesh-operation-01",
    "listenerId": "listener-west"
  }
}
{
  "requestId": "credential-request-01",
  "providerReference": "listener-slot:listener-west",
  "receiptSha256": "91...64-hex-characters...ac"
}

The receipt is deliberately non-secret. It may identify provider-owned state and hash a provider-owned receipt, but it must not echo material, protected paths, or write-only configuration. Hovel records a secret-free execution plan and terminal receipt. For a consuming Mesh listener, task, or stream, Hovel sends this call in the same module process immediately before the consuming call; a standalone RPC process does not preserve in-memory credentials for a later invocation.

credential.files

Delivers bounded, owner-protected, invocation-scoped files. Each entry names its projection, material form, byte encoding, media type, digest, and size. Paths are valid only for this invocation unless the provider deliberately copies them into provider-owned storage and reports that action through a non-secret receipt.

{
  "schemaVersion": "hovel.pki.provider-execution/v1",
  "provider": CredentialProviderTarget,
  "requestId": "credential-request-02",
  "assignmentId": "assignment-listener-west",
  "slotName": "control-plane-mtls",
  "credential": ResolvedCredentialMetadata,
  "files": [{
    "projection": "certificate-der",
    "form": "public",
    "encoding": "raw",
    "mediaType": "application/pkix-cert",
    "path": "/owner-protected/invocation/certificate.der",
    "sha256": "a7...64-hex-characters...10",
    "size": 812
  }],
  "scope": CredentialOperationScope
}
{
  "requestId": "credential-request-02",
  "providerReference": "listener-files:listener-west"
}

credential.encode

Asks one advertised provider encoding schema to transform a resolved projection. The request supplies a hard byte limit. The result carries the requested bytes, their form, encoding name, and SHA-256; the daemon validates the request id, schema, form, bound, and digest before those bytes can enter another provider call. Encoded bytes remain secret-bearing transient data, not a normal list or inspect response.

{
  "schemaVersion": "hovel.pki.provider-execution/v1",
  "provider": CredentialProviderTarget,
  "requestId": "credential-encode-01",
  "providerId": "picblobs",
  "providerSchema": "picblobs.nacl-config/v1",
  "outputForm": "private-bytes",
  "maximumEncodedBytes": 4096,
  "source": ResolvedCredentialMaterial,
  "scope": CredentialOperationScope
}
{
  "requestId": "credential-encode-01",
  "form": "private-bytes",
  "encoding": "picblobs.nacl-config/v1",
  "sha256": "b5...64-hex-characters...3e",
  "data": "AAECAwQFBgcICQ=="
}

credential.stamp

Applies one persisted, descriptor-validated stamp plan to the exact input artifact and short-lived resolved material. Standard stamping uses a named slot. Advanced providers may advertise file offsets, virtual addresses, symbols, markers, byte patterns, or versioned provider-defined targets with bounds and preconditions. Offsets, addresses, and byte counts use canonical base-10 strings where the full unsigned 64-bit range must survive JSON clients.

{
  "schemaVersion": "hovel.pki.provider-execution/v1",
  "provider": CredentialProviderTarget,
  "stampId": "credential-stamp-01",
  "request": CredentialStampRequest,
  "input": {
    "id": "artifact-01",
    "sha256": "42...64-hex-characters...9f",
    "encoding": "raw",
    "path": "/owner-protected/invocation/payload.bin"
  },
  "resolvedMaterial": ResolvedCredentialMaterial,
  "expectedDigests": [{
    "projection": "bundle",
    "reference": "generation:leaf-01",
    "sha256": "6d...64-hex-characters...91"
  }],
  "scope": CredentialOperationScope
}
{
  "stampId": "credential-stamp-01",
  "output": {
    "artifact": {
      "name": "payload-stamped.bin",
      "encoding": "raw",
      "path": "/owner-protected/invocation/payload-stamped.bin"
    }
  },
  "targetResolution": "unchanged",
  "resolvedTarget": { "kind": "named-slot", "namedSlot": { "name": "control-plane-mtls" } },
  "bytesWritten": "4096",
  "materialDigests": [{
    "projection": "bundle",
    "reference": "generation:leaf-01",
    "sha256": "6d...64-hex-characters...91"
  }]
}

Input and artifact output each carry exactly one of data or path. The stamp output carries exactly one of artifact or deployment. A deployment response contains an opaque reference and a secret-aware receipt; Hovel hashes the receipt and stores only non-secret destination evidence in durable stamp bookkeeping.

step.describe

Reports the module's current chain-step contracts. The current host calls this during catalog inspection and uses the resulting catalog snapshot during validation, module inspection, planning, and capability-step execution. Persisting the exact returned snapshot when the operator confirms, then blocking execution if a referenced contract changes after confirmation, is a target safeguard that is not implemented yet.

handshake, schema, and step.describe are inspection paths. They must be deterministic and side-effect free: no target connections, filesystem mutation, listener startup, payload byte generation, credential creation, or random prepared values. Put those actions in step.prepare, step.execute, or execute according to the lifecycle rules below.

Planning treats the returned requirements as typed capability predicates. Hovel compares requirement type, attributes, and allowed states against the current capability set, then records either a deterministic capability binding or a typed missing requirement. module inspect --json exposes that same resolved view for operator tooling.

{
  "steps": [
    {
      "id": "squatter.connect_smb",
      "kind": "session.connector",
      "configSchema": { "type": "object", "properties": {} },
      "requires": [
        {
          "type": "PayloadInstance",
          "schemaVersion": "v1",
          "attributes": {
            "provider": "squatter",
            "transport": "smb-named-pipe"
          },
          "states": ["installed", "connected", "unreachable"]
        },
        {
          "type": "CredentialCapability",
          "schemaVersion": "v1",
          "attributes": { "protocol": "smb" },
          "states": ["active"]
        }
      ],
      "produces": [
        {
          "type": "SessionRef",
          "schemaVersion": "v1",
          "attributes": {
            "provider": "squatter",
            "transport": "smb-named-pipe"
          }
        }
      ],
      "prepare": { "materializes": [] },
      "cleanup": null
    }
  ]
}

step.prepare

Validates step config and materializes planned values. This method is pure and local: it may generate random names, credentials, paths, endpoints, and planned cleanup handles, but it must not connect to a target, authenticate, stage files, start listeners that accept target traffic, or produce final payload bytes. Repeated prepare for an existing prepared plan preserves materialized values unless the operator explicitly regenerates or edits them.

{
  "preparedPlanId": "prep-1",
  "stepId": "windows.credential.create_local_admin",
  "config": { "usernamePolicy": "random", "passwordLength": 24 },
  "inputs": [
    { "capabilityId": "cap_remote_execution_f8a21b", "type": "RemoteExecutionCapability" }
  ],
  "existingPreparedValues": {}
}
{
  "plannedOutputs": [
    {
      "id": "cap_credential_6mb8pq",
      "type": "CredentialCapability",
      "schemaVersion": "v1",
      "state": "planned",
      "attributes": {
        "protocol": "smb",
        "username": "m7q4z92d",
        "password": "plain-high-entropy-password",
        "sensitive": true
      }
    }
  ],
  "preparedValues": {
    "username": { "value": "m7q4z92d", "editable": true },
    "password": { "value": "plain-high-entropy-password", "editable": true }
  },
  "operatorSummary": {
    "warnings": [],
    "targetSideArtifacts": ["local admin user m7q4z92d"]
  }
}

step.execute

Executes a confirmed step. The daemon supplies only the capabilities declared by the step contract plus minimal run metadata. A step should be idempotent where possible and must use confirmed prepared values exactly; confirmed collisions fail rather than silently changing target-side names.

{
  "runId": "run-1",
  "stepId": "squatter.install_smb",
  "confirmedPreparedValues": {
    "stagedPath": "C:\\Windows\\Temp\\n4x9q2.exe",
    "serviceName": "q7mcz91a",
    "pipeName": "\\\\.\\pipe\\a8kq27mf"
  },
  "inputs": [
    { "capabilityId": "cap_remote_execution_f8a21b", "type": "RemoteExecutionCapability" },
    { "capabilityId": "cap_credential_6mb8pq", "type": "CredentialCapability" },
    { "capabilityId": "cap_payload_artifact_vc9s31", "type": "PayloadArtifact" }
  ]
}
{
  "status": "succeeded",
  "capabilities": [ Capability, ... ],
  "installedPayloads": [ InstalledPayloadDescriptor, ... ],
  "stateTransitions": [ CapabilityTransition, ... ],
  "evidence": [ Evidence, ... ]
}

installedPayloads is optional and is the only way a module or provider can ask Hovel to create or update installed payload inventory. Hovel validates each descriptor against the named payload provider before persistence. Logs, artifacts, and generic evidence are never promoted into installed payload records by inference.

step.cleanup

Executes or verifies cleanup for a durable CleanupHandle. Cleanup defaults to the module that created the handle. Another module may consume a cleanup handle only when both modules explicitly declare compatibility. Cleanup is retriable until the handle reaches removed, cleanup_verified, or the operator marks it abandoned.

{
  "runId": "run-1",
  "stepId": "squatter.cleanup_smb",
  "cleanupHandleId": "cap_cleanup_74m2wq",
  "mode": "execute"                  // execute | verify
}
{
  "status": "removed",
  "stateTransitions": [ CapabilityTransition, ... ],
  "evidence": [ Evidence, ... ]
}

list_payloads

Lists provider-supported payloads for live discovery. Hovel passes a PayloadQuery directly as params. The provider returns a JSON array of PayloadInfo objects; there is no wrapper object.

{
  "platform": "windows",
  "arch": "x86",
  "format": "pe-exe",
  "transport": "tcp-bind",
  "config": { "lhost": "10.0.0.5" },
  "capabilities": ["session.shell"]
}
[
  PayloadInfo,
  ...
]

resolve_payload

Resolves one provider-supported payload from a query. Planning uses this for compatibility and review metadata; final bytes are still generated after confirmation by generate_payload.

{
  "target": "10.0.0.42",
  "platform": "windows",
  "arch": "x86",
  "format": "pe-exe",
  "transport": "tcp-bind"
}
PayloadInfo

prepare_listener

Prepares a provider-managed listener before payload generation when the payload acquisition model requires one. This is local provider setup, not target contact.

{
  "runId": "run-1",
  "target": "10.0.0.42",
  "payloadId": "squatter/windows/x86/windows-7/tcp-bind/pe-exe",
  "config": { "lhost": "10.0.0.5", "lport": "9101" }
}
ListenerRef

generate_payload

Generates or materializes payload artifacts after confirmation. The provider returns a primary artifact plus optional side artifacts. Inline bytes are base64; large artifacts may be exposed through a provider handle and streamed with read_payload_chunk.

{
  "runId": "run-1",
  "target": "10.0.0.42",
  "payloadId": "squatter/windows/x86/windows-7/tcp-bind/pe-exe",
  "format": "pe-exe",
  "config": { "lhost": "10.0.0.5", "lport": "9101" },
  "listener": ListenerRef
}
PayloadArtifactSet

connect_session

Connects to an installed payload from a Hovel-persisted descriptor and returns a normal SessionRef. The provider owns compatibility checks for its reconnect schema. Hovel owns session registration and installed-payload state persistence.

{
  "runId": "run-1",
  "target": "10.0.0.42",
  "payloadId": "squatter/windows/x86/windows-7/tcp-bind/pe-exe",
  "installedPayloadId": "installed-payload-uuid",
  "config": { "timeout": "10s" },
  "reconnect": PayloadProviderRecord
}
SessionRef

cleanup_payload

Attempts provider-executed cleanup for an installed payload. A successful provider result returns { "status": "ok" }; Hovel then marks the installed payload removed. Failures are recorded as cleanup-attempt events and leave the record active.

{
  "runId": "run-1",
  "target": "10.0.0.42",
  "payloadId": "squatter/windows/x86/windows-7/tcp-bind/pe-exe",
  "installedPayloadId": "installed-payload-uuid",
  "reason": "operator cleanup",
  "cleanup": PayloadProviderRecord
}
{
  "status": "ok"
}

read_payload_chunk

Reads bytes from a provider-owned artifact handle returned by generate_payload. The result data is base64-encoded.

{
  "handle": "provider-artifact-handle",
  "offset": 0,
  "length": 65536
}
{
  "handle": "provider-artifact-handle",
  "offset": 0,
  "data": "TVqQAAMAAAAEAAAA...",
  "eof": false,
  "encoding": "base64"
}

mesh.describe

Reports a provider-owned Mesh's static capabilities, task specs, declared triggers, and cheap topology. A Mesh can be one node, a chain, or a routed tree/graph; it is broader than a transport or tunnel. This is the only common method every Mesh provider should implement. Other Mesh methods are optional and should match the descriptor's advertised capabilities.

{
  "agentContext": AgentContext
}
{
  "name": "routed-node-mesh",
  "version": "v0.1.0",
  "capabilities": ["topology.tree", "task.survey", "task.command", "task.load", "stream.tcp"],
  "tasks": [
    { "kind": "survey", "summary": "survey a node", "readOnly": true, "targetScopes": ["node"] },
    { "kind": "upload_execute", "summary": "upload and execute a file", "destructive": true, "targetScopes": ["node", "destination"] },
    { "kind": "load", "summary": "load a provider-native component or implant", "destructive": true, "targetScopes": ["node", "destination"] },
    { "kind": "command", "summary": "run a node or routed destination command", "targetScopes": ["node", "destination"] }
  ],
  "listenerTypes": [
    {
      "kind": "https-beacon",
      "summary": "HTTPS beacon rendezvous",
      "deployments": ["embedded", "separate"],
      "managementModes": ["provider", "external"],
      "protocols": ["https"],
      "capabilities": ["beacon"]
    }
  ],
  "triggers": [
    { "id": "beacon-command", "kind": "beacon", "nodeId": "node-2", "state": "armed" }
  ]
}

mesh.topology

Returns current nodes, links, and optional routes. This may inspect provider-local state but must not mutate target-side state.

{
  "root": "controller",
  "includeRoutes": true
}
{
  "root": "controller",
  "nodes": [
    { "id": "controller", "kind": "controller", "state": "online" },
    { "id": "relay-1", "parentId": "controller", "kind": "relay", "state": "online" },
    { "id": "leaf-1", "parentId": "relay-1", "kind": "agent", "state": "online" }
  ],
  "links": [
    { "id": "link-controller-relay-1", "source": "controller", "target": "relay-1", "state": "up" }
  ],
  "routes": [
    { "id": "route-leaf-1", "nodes": ["controller", "relay-1", "leaf-1"] }
  ]
}

mesh.beacons

Returns recent node liveness or status signals. Beacons are evidence and routing context; they never bypass planning, confirmation, or guardrails.

{
  "nodeId": "leaf-1",
  "since": "2026-07-09T00:00:00Z",
  "limit": 50
}
{
  "beacons": [
    { "id": "beacon-1", "nodeId": "leaf-1", "state": "alive", "time": "2026-07-09T00:01:00Z" }
  ]
}

mesh.listeners

Lists dynamic provider-reported listening-post state, optionally filtered by stable listener id or provider-defined state. Listener configuration and credentials are deliberately absent from this read model.

{
  "listenerId": "listener-west"
}
{
  "listeners": [
    {
      "id": "listener-west",
      "kind": "https-beacon",
      "state": "active",
      "deployment": "separate",
      "management": "provider",
      "addresses": ["https://127.0.0.1:8443"],
      "protocols": ["https"]
    }
  ]
}

mesh.listener.start

Idempotently starts or attaches a listening post using a caller-selected stable listenerId. config is provider-specific and write-only: implementations must not copy it into the returned listener, logs, or daemon bookkeeping. Long-lived resources must remain durable across individual module RPC invocations; the module adapter is not itself a listening-post host.

{
  "listenerId": "listener-west",
  "name": "west rendezvous",
  "kind": "https-beacon",
  "deployment": "separate",
  "management": "provider",
  "config": { "bind": "127.0.0.1:8443", "credentialRef": "vault://listener-west" }
}
{
  "id": "listener-west",
  "kind": "https-beacon",
  "state": "active",
  "deployment": "separate",
  "management": "provider",
  "addresses": ["https://127.0.0.1:8443"]
}

mesh.listener.stop

Idempotently stops or detaches the caller-named listening post. The returned listener must preserve the requested id so external control planes can correlate retries and daemon audit records.

{
  "listenerId": "listener-west"
}
{
  "id": "listener-west",
  "state": "stopped"
}

mesh.task

Runs a provider-owned node, route, or destination task. This is a low-level, execution-capable primitive exposed only through the daemon's privileged local control transport. It does not create, review, or confirm a throw plan. A front end must apply Hovel's normal persisted-plan, confirmation, dangerous-module, and launch-key checks before invoking it when the requested action is a throw. Common task kinds are survey, upload, execute, upload_execute, command, load, and stream. Destination-scoped tasks use nodeId or route as the pivot and destinationHost, destinationPort, and protocol as the endpoint reachable through that pivot. Implant loading is modeled the same way: use upload_execute for "copy bytes, then run them" and load only when the provider owns a native loader. Payload bytes may be inline inputData with inputEncoding, or a provider-defined artifact/payload reference in config. Providers do not need to implement every task kind; unsupported methods or task kinds should fail explicitly.

{
  "runId": "run-1",
  "taskId": "upload-execute-through-node-2",
  "kind": "upload_execute",
  "nodeId": "node-2",
  "destinationHost": "10.10.10.10",
  "destinationPort": 445,
  "protocol": "tcp",
  "config": { "remotePath": "C:\\\\Windows\\\\Temp\\\\payload.exe" },
  "inputData": "TVqQAAMAAAAEAAAA...",
  "inputEncoding": "base64"
}
{
  "taskId": "upload-execute-through-node-2",
  "status": "succeeded",
  "summary": "uploaded and executed through node-2",
  "nodeId": "node-2",
  "destinationHost": "10.10.10.10",
  "destinationPort": 445,
  "protocol": "tcp",
  "outputs": { "exitCode": 0 },
  "findings": [],
  "sessions": []
}

mesh.open_stream

Opens a routed session flow to an endpoint reachable through a Mesh node or route. The result is a normal SessionRef, so existing session IO, transcript capture, and TCP/UDP local socket bridging use the same broker. UDP bridge sessions must advertise the datagram capability; each non-empty read or write then represents exactly one datagram.

{
  "runId": "run-1",
  "moduleId": "routed-node-mesh@v0.1.0",
  "nodeId": "node-2",
  "destinationHost": "10.10.10.10",
  "destinationPort": 443,
  "protocol": "tcp"
}
SessionRef

execute

Runs the module against one target. The daemon supplies the resolved configuration; the module returns its result.

{
  "runId": "run-1",
  "moduleId": "mock-survey@v0.0.0-example",
  "target": "mock://host",
  "inputs": { "failure_mode": "" },
  "chainConfig":  { "operator.confirmed_lab": true },
  "targetConfig": { "target.host": "example.test", "target.port": "443" }
}
{
  "status": "succeeded",             // or "failed"
  "summary": "example survey reached example.test:443",
  "findings":  [ Finding, ... ],
  "artifacts": [ Artifact, ... ],
  "outputs":   { "facts": { "host": "example.test", "reachable": true } },
  "installedPayloads": [ InstalledPayloadDescriptor, ... ],
  "sessions":  [ SessionRef, ... ]
}

While execute runs, the module may stream module/log and module/session notifications. The daemon resolves a configuration key by preferring per-run inputs, then targetConfig, then chainConfig.

session/write, session/read, session/close

Drive a session the module opened during execute. Payloads are base64 so arbitrary bytes survive JSON.

// write operator input
→ {"method":"session/write","params":{"sessionId":"run-1-session-1","data":"d2hvYW1pCg=="}}
← {"result":{"status":"ok"}}

// read output (timeoutMs < 0 blocks; the daemon usually passes a finite timeout)
→ {"method":"session/read","params":{"sessionId":"run-1-session-1","timeoutMs":1000}}
← {"result":{"sessionId":"run-1-session-1","data":"bW9jay1vcGVyYXRvcgo=","closed":false}}

// close
→ {"method":"session/close","params":{"sessionId":"run-1-session-1","reason":"done"}}
← {"result":{"status":"ok"}}

shutdown

Asks the module to close any sessions and exit. The module replies {"status":"ok"} and then terminates.

Notifications (module → daemon)

module/log

A structured log record. Appears in the throw transcript alongside the daemon's own logs.

{
  "level": "info",                   // debug | info | warning | error
  "message": "connecting to target",
  "logger": "mock-survey",
  "fields": { "host": "example.test", "port": "443" },
  "exception": ""
}

module/session

A session lifecycle event. The daemon's session broker tracks these.

{
  "event": "session.created",        // or "session.closed"
  "session": SessionRef,
  "fields": { "reason": "closed" }   // present on session.closed
}

Notifications (daemon → module)

cancel

Best-effort cancellation notification. It has no id and expects no response. Current SDKs may log it or use it to interrupt future cancellable work; the daemon still owns process teardown when a module does not stop cooperatively.

{
  "reason": "operator requested cancel"
}

Data shapes

ModuleInfo

{
  "name": "mock-exploit",
  "version": "v0.0.0-example",
  "moduleType": "exploit",
  "summary": "Run an example exploit flow.",
  "description": "Example module for the Hovel stdio JSON-RPC runtime.",
  "tags": ["example", "exploit"]
}

ModuleSchema

{
  "chainConfig": [ Requirement, ... ],
  "targetConfig": [ Requirement, ... ],
  "outputs": {
    "facts": { "type": "object" }
  }
}

ExecuteRequest

{
  "runId": "run-1",
  "moduleId": "mock-exploit@v0.0.0-example",
  "target": "mock://host",
  "inputs": { "failure_mode": "" },
  "chainConfig": { "operator.confirmed_lab": true },
  "targetConfig": { "target.host": "example.test", "target.port": "443" }
}

ModuleResult

{
  "status": "succeeded",
  "summary": "module completed",
  "findings": [ Finding, ... ],
  "artifacts": [ Artifact, ... ],
  "outputs": {},
  "sessions": [ SessionRef, ... ],
  "installedPayloads": [ InstalledPayloadDescriptor, ... ]
}

Requirement

{
  "key": "target.host",
  "type": "host",        // string | secret | bool | int | float | enum |
                         // duration | url | host | port | cidr | path | list | map
  "required": true,
  "default": "",
  "description": "Target host name or IP address.",
  "allowed": [],         // enumerated values, when constrained
  "secret": false        // hide the value in logs and transcripts
}

Finding

{ "title": "example exploit path verified", "severity": "info", "detail": "..." }

Artifact

Either inline data or a path on disk.

{ "name": "transcript.txt", "kind": "text/plain", "data": "..." }
{ "name": "loot.bin",       "kind": "application/octet-stream", "path": "/tmp/loot.bin" }

SessionRef

{
  "id": "run-1-session-1",
  "runId": "run-1",
  "moduleId": "mock-exploit-session",
  "target": "mock://host",
  "name": "mock shell on mock://host",
  "kind": "shell",
  "state": "active",                 // active | closed
  "transport": "stdio",
  "installedPayloadId": "installed-payload-uuid",
  "capabilities": ["read", "write", "exec", "close"]
}

Session Requests

{
  "sessionId": "run-1-session-1",
  "data": "d2hvYW1pCg=="              // session/write, base64 operator input
}

{
  "sessionId": "run-1-session-1",
  "timeoutMs": 1000                   // session/read; negative means block
}

{
  "sessionId": "run-1-session-1",
  "reason": "done"                    // session/close
}

SessionReadResult

{
  "sessionId": "run-1-session-1",
  "data": "bW9jay1vcGVyYXRvcgo=",      // base64 session output
  "closed": false
}

Capability

Capabilities are typed chain state. Core capability schemas are owned by Hovel and versioned; modules may add namespaced extension attributes. Capability ids are readable structured identifiers with random entropy, and run/step linkage is carried as fields rather than encoded as the only source of truth.

{
  "id": "cap_payload_instance_q8m2v4",
  "type": "PayloadInstance",
  "schemaVersion": "v1",
  "state": "installed",
  "producerStepId": "squatter.install_smb",
  "attributes": {
    "provider": "squatter",
    "transport": "smb-named-pipe",
    "endpointId": "cap_endpoint_smb_pipe_c4a7nx"
  },
  "extensions": {
    "squatter": {
      "artifactSha256": "abc123",
      "reconnectStrategy": "active_connect"
    }
  }
}

Core capability types for the Squatter chain are RemoteExecutionCapability, CredentialCapability, PayloadArtifact, PayloadInstance, TransportEndpoint, SessionRef, and CleanupHandle. A step may still emit partial capabilities with explicit states and warning evidence, such as an installed payload record that reaches unreachable when reconnect fails, or a cleanup handle in cleanup_failed.

InstalledPayloadDescriptor

An installed payload descriptor is an explicit module/provider claim that a target-side payload instance exists and may be tracked. Hovel core validates and persists it; modules and providers never write installed payload rows directly.

{
  "provider": "squatter",
  "payloadId": "squatter/windows/x86/windows-7/tcp-bind/pe-exe",
  "payloadVersion": "0.1.0",
  "target": "10.0.0.42",
  "targetId": "t1",
  "state": "installed",
  "transport": "tcp-bind",
  "endpoint": "10.0.0.42:9101",
  "instanceKey": "provider-stable-key-or-empty",
  "stampId": "payload-stamp-uuid",
  "artifactIds": ["artifact-squatter-exe"],
  "supportsReconnect": true,
  "supportsMultipleSessions": false,
  "reconnect": {
    "providerId": "squatter",
    "schema": "squatter.reconnect.tcp_bind",
    "schemaVersion": "1",
    "descriptor": { "host": "10.0.0.42", "port": 9101 }
  },
  "cleanup": {
    "providerId": "squatter",
    "schema": "squatter.cleanup.tcp_bind",
    "schemaVersion": "1",
    "descriptor": { "remotePath": "C:\\Windows\\Temp\\n4x9q2.exe" }
  },
  "metadata": {
    "profile": "xp-sp3-lab"
  }
}

The accepted current states are installed, connected, unreachable, and removed. Failed installs are reported as step failure evidence, not installed payload descriptors. When a provider can prove a repeated report is the same deployed instance using instanceKey or stampId, Hovel may upsert the existing record; otherwise it creates a new operator-facing handle.

PayloadQuery

{
  "target": "10.0.0.42",
  "kind": "pe",
  "platform": "windows",
  "os": "windows",
  "arch": "x86",
  "format": "pe-exe",
  "tags": ["pe", "windows"],
  "transport": "tcp-bind",
  "config": { "lhost": "10.0.0.5" },
  "capabilities": ["session.shell"]
}

PayloadInfo

{
  "id": "squatter/windows/x86/windows-7/tcp-bind/pe-exe",
  "name": "Squatter TCP bind PE",
  "version": "0.1.0",
  "kind": "pe",
  "platform": "windows",
  "os": "windows",
  "arch": "x86",
  "minOS": "Windows 7",
  "testedOS": ["Windows 7"],
  "formats": ["pe-exe", "pe"],
  "tags": ["pe", "windows", "agent"],
  "capabilities": ["session.shell"],
  "transport": {
    "kind": "tcp-bind",
    "encrypted": false
  },
  "session": {
    "kind": "shell",
    "acquisition": "connect",
    "requiresPreThrowListener": false,
    "requiresPostThrowConnect": true,
    "owner": "provider"
  }
}

ListenerRef

{
  "id": "listener-1",
  "runId": "run-1",
  "target": "10.0.0.42",
  "transport": "tcp-bind",
  "host": "10.0.0.5",
  "port": 9101,
  "pipe": "",
  "state": "listening",
  "fields": { "interface": "lab0" }
}

PayloadArtifactSet

{
  "primary": PayloadArtifact,
  "artifacts": [ PayloadArtifact, ... ]
}

PayloadArtifact

{
  "name": "squatter.exe",
  "role": "primary",
  "kind": "pe",
  "format": "pe-exe",
  "os": "windows",
  "arch": "x86",
  "tags": ["pe", "windows"],
  "encoding": "base64",
  "bytes": "TVqQAAMAAAAEAAAA...",
  "handle": "",
  "size": 123456,
  "sha256": "hex-sha256"
}

PayloadProviderRecord

{
  "providerId": "squatter",
  "schema": "squatter.reconnect.tcp_bind",
  "schemaVersion": "1",
  "descriptor": { "host": "10.0.0.42", "port": 9101 }
}

Provider Requests

{
  "runId": "run-1",
  "target": "10.0.0.42",
  "payloadId": "squatter/windows/x86/windows-7/tcp-bind/pe-exe",
  "config": {},
  "listener": ListenerRef,             // generate_payload only
  "reconnect": PayloadProviderRecord,  // connect_session only
  "cleanup": PayloadProviderRecord,    // cleanup_payload only
  "installedPayloadId": "installed-payload-uuid",
  "reason": "operator cleanup"
}

CleanupResult

{
  "status": "ok"
}

PayloadChunk

{
  "handle": "provider-artifact-handle",
  "offset": 0,
  "data": "TVqQAAMAAAAEAAAA...",
  "eof": false,
  "encoding": "base64"
}

Credential provider shapes

ShapeInvariantDurable visibility
CredentialProviderTargetExact module id, provider id/version, and descriptor SHA-256.Persisted provenance.
ResolvedCredentialMaterialExactly one bytes or provider-scoped reference variant, consistent with form.Only projection, form, encoding, digest, and size are projected into bookkeeping.
CredentialFileInvocation-scoped protected path with byte encoding, media type, digest, and size.Path is stripped; metadata remains.
CredentialDeliveryReceiptMatching request id plus optional non-secret provider reference and receipt digest.Only SHA-256 digests of the provider reference and receipt are persisted; the raw values are not.
CredentialEncodingResultMatching request id, bounded bytes, declared form/encoding, and matching digest.Only form, encoding, digest, and size are persisted.
CredentialArtifactInput/OutputExactly one inline-data or protected-path variant.Final artifact/destination metadata after daemon verification and ingestion.
CredentialStampOutputExactly one artifact or deployment variant.Resolved target, counts, material digests, and non-secret destination evidence.

SDK secret wrappers redact ordinary debug/string formatting. That is defense in depth, not authorization to log secrets: provider code must still avoid logging, retaining, returning in errors, or copying private bytes and protected paths. Credential byte fields use canonical padded RFC 4648 base64 on the wire.

CredentialCapability

Target-created credentials are assessment evidence. They are stored and displayed in plaintext in normal operator CLI output so the operator can remediate manually if automated cleanup fails. This chain capability is not a workspace-PKI bundle, certificate generation, private key, assignment, or provider delivery. The sensitive marker labels the field for evidence exports and downstream handling; it is not a CLI redaction request.

{
  "id": "cap_credential_6mb8pq",
  "type": "CredentialCapability",
  "schemaVersion": "v1",
  "state": "active",
  "attributes": {
    "protocol": "smb",
    "principal": "local",
    "username": "m7q4z92d",
    "password": "plain-high-entropy-password",
    "secretType": "password",
    "targetScope": "10.0.0.42",
    "sensitive": true,
    "durability": "until_cleanup"
  }
}

Evidence

Evidence is operator and audit material, not chain input. It uses a common envelope with flexible module-specific details so modules can report rich diagnostics without forcing core schema churn.

{
  "id": "ev_squatter_connect_warning_2m7kq9",
  "level": "warn",
  "kind": "payload.unreachable",
  "sourceStepId": "squatter.connect_smb",
  "message": "Squatter installed but no session was established.",
  "details": {
    "target": "10.0.0.42",
    "endpoint": "\\\\10.0.0.42\\pipe\\a8kq27mf",
    "error": "NT_STATUS_ACCESS_DENIED"
  }
}

Lifecycle summary

Lifecycle Summary
daemon module
  1. daemon module handshake
  2. module daemon {name,type,...}
  3. daemon module schema
  4. module daemon {chainConfig,...}
  5. daemon module execute(target)
  6. module daemon module/logn times during execute
  7. module daemon module/sessiononly if opened
  8. module daemon {status,...}
  9. daemon module session/read|write|closeonly if opened
  10. daemon module shutdown
  11. module daemon {status:ok}
  12. module exit process ends