sidecar

package
v0.2.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 3, 2026 License: MIT Imports: 18 Imported by: 0

README

Sidecar SDK & Protocol

Build protocol adapters that integrate with sectool's capture, rule, replay, and analysis surface. Any protocol, MQTT, proprietary RPC, or custom binary framing, can present its traffic into sectool's unified flow timeline and reuse the existing toolset with full agent parity.

Two ways to implement an adapter:

  • Go SDK — the sidecar package handles registration, rule caching, and flow emission so your adapter focuses on parsing and sending protocol frames.
  • JSON-RPC 2.0 wire protocol — sidecars can be written in any language; they just speak the length-prefixed JSON-RPC 2.0 contract over a local socket.

A shipped example: sidenuclei is a first-party Go sidecar that claims no connections at all — it polls captured flows via core_invoke, scans each endpoint with Nuclei, and files findings back as notes, showing how a sidecar reuses the core toolset on its own.


Concepts

Architecture

Sectool owns all sockets, client-facing and upstream. Bytes flow over the IPC connection as base64 encoded fields inside JSON-RPC 2.0 messages, keeping the contract portable across Linux, macOS, and Windows.

The captured representation maps to an HTTP-shaped envelope (method, path, headers, body) with an adapter-defined protocol_tag in the slot HTTP uses for its version. Every existing sectool tool (flow_get, diff_flow, find_reflected, replay_send, notes_save, …) then works on adapter flows without per-adapter schema reasoning.

Connection lifecycle
  1. Connect & register. Sidecar dials the socket and sends a register request (see register). Sectool replies with the effective version, or rejects with -33001 on version mismatch.
  2. Rule sync. Rules arrive only through sync_rules, pushed at registration and on every change (see sync_rules).
  3. Capture. Sidecar emits flows via push_flow; two-phase completion, streams, and sessions all use the same method (see Flow model).
  4. Data path. For claimed connections, sectool delivers inbound bytes to the sidecar, which returns bytes to write back (possibly to a different stream) and may nest dial_upstream, core_invoke, or push_flow (see stream_deliver).
  5. Heartbeat. ping / pong keep the connection alive in both directions.
  6. Shutdown. Sectool sends shutdown; the sidecar drains, emits final metrics, and closes.
Transport

Sidecars connect to sectool over a local socket resolved from config:

OS Transport Default address
Unix (Linux, macOS) Unix domain socket ~/.sectool/sidecar.sock
Windows Loopback TCP (127.0.0.1) auto-assigned port in config

Override via the --sidecar-socket <value> CLI flag or the sidecar_socket field in ~/.sectool/config.json. The network is inferred from shape: a host:port uses TCP, anything else uses a Unix domain socket. Sectool binds the address and an attached sidecar dials it, both resolving the same config value, so no custom configuration is needed.

Versioning

The protocol_version ({major, minor}) gates the session during register. The Go SDK defaults to the toolbox release the sidecar was built against.

  • Major mismatch → hard reject with -33001 (ErrVersionUnsupported).
  • Minor ≤ sectool's minor → accepted; session runs at the sidecar's minor.
  • Sidecar minor > sectool's minor → rejected with -33001; update sectool.
Flow model

A Flow is one logical exchange. It MAY carry a request side and a response side, each an envelope of {method, path, query, status_code, status_text, headers, body}. The envelope fields live inside the sub-objects; flow-level fields (protocol_tag, direction, parent_flow_id, timestamps, annotations) sit alongside.

  • Request/response protocols populate both sides under one flow_id.
  • One-way messages (tunnel envelopes, stream chunks, pub/sub frames) populate a single side and rely on direction (client_to_server | server_to_client | bidirectional).
  • Two-phase completion — emit the request side first (sectool returns a flow_id), attach the response later by re-emitting with the same flow_id.
  • Streams and sessions — a parent flow plus child flows that set parent_flow_id. Children are stored and replayed in emission order; sectool never reorders, so there is no per-chunk sequence number. A direction=bidirectional, method=TUNNEL parent is a session/tunnel envelope; its flow_id is the grouping key.
  • Non-decodable bodies — when the wire form is not natively decodable by sectool (protobuf, custom framing), supply the logical body plus body_raw (verbatim wire bytes) and body_codec (the transform chain and content-type). Unmutated replay resends body_raw; a mutated body is re-encoded through body_codec.
Capabilities

A sidecar declares which connection-handling seams it claims at registration. Each kind is a list (early_claims, upgrade_claims, injection_targets), so one registration can claim several protocol entry points:

  • early_claim — claim TCP connections from accept on a port range, optionally gated by magic_bytes_prefix, host_match, sni_match, or a dynamic probe. With tls.terminate, sectool MitMs TLS and the sidecar receives the decrypted contents. tls.cert optionally declares additive SANs (dns_names, ip_addresses, uris, emails) and a legacy common_name to mint onto the terminated leaf, for clients that verify a name (or URI/SPIFFE identity) other than the SNI they dial. The declaration is purely additive; the leaf always retains the dialed name. A connection matching no claim falls through to the HTTP adapter.
  • upgrade_claim — claim a byte stream after an HTTP upgrade (http_101 or connect). Sectool captures the triggering request as a normal flow, synthesizes the upgrade response, and routes subsequent bytes to the sidecar; the captured request's flow_id and headers are surfaced on stream open.
  • injection_target — declare the adapter can originate new outbound messages, enabling replay_send routing and cross-adapter invoke_adapter.

A sidecar with multiple claims routes an inbound stream on the protocol input stream_open carries — host/path/request_headers for an upgrade claim, host plus the opening stream_deliver bytes for an early claim.

Any sidecar may emit flows and apply pushed rules without declaring a capability. Conflicts (overlapping port ranges, ambiguous matchers, duplicate names) are rejected at registration time, naming both parties.

Early claim matching

An early claim is offered connections at two seams, and every matcher it declares is applied at both. An omitted matcher matches anything: an unset port_range ({"low": 0, "high": 0}) spans every port, and an empty host_match or sni_match matches any host or SNI.

raw accept decrypted post-CONNECT stream
port_range the proxy's listen port the CONNECT target port
host_match not applicable the CONNECT target host
sni_match not applicable the ClientHello SNI, for tls.terminate claims
magic_bytes_prefix, probe applied applied

A tls.terminate claim gates termination itself: sectool MitMs a matching connection with its own CA and offers that claim the decrypted stream first. Every other claim sees the stream only if the terminating claim declines, and is still gated by its own matchers — a claim never receives traffic outside what it declared.

Claims rejected at registration:

  • an early claim covering the native proxy port with no magic_bytes_prefix, probe, or tls.terminate — it would swallow all proxy traffic (capability_conflict)
  • an invalid port_range (inverted or outside 1–65535), a magic_bytes_prefix that is not non-empty standard base64, a negative probe_max_bytes, an unknown upgrade_signal, or a pattern that is not valid RE2 (registration_rejected)

A raw claim and a tls.terminate claim occupy separate seams, so one registration may declare both for the plain and TLS forms of a protocol.

Upgrade claim matching

host_pattern and path_pattern are RE2 patterns matched against the whole value (compiled as ^(?:pattern)$), so app\.example\.com matches only that host while app.example.com also matches appXexample!com — escape metacharacters for an exact match. An empty pattern matches anything, and path_pattern is matched against the path with any query string removed. An empty upgrade_signal means http_101, which additionally requires an Upgrade header on the request.

When two claims can match the same request, the more specific one wins: a literal pattern outranks a regex, which outranks an empty pattern. Two overlapping claims where neither is strictly more specific are rejected at registration.

Mutation ownership

A flow's content is mutated by exactly one party: sectool itself for native HTTP/WS flows, or the sidecar that captured it for adapter-owned flows. Sectool never mutates a sidecar-owned flow's plaintext (it relays opaque/ciphertext bytes), so nothing outside the owner ever rewrites that flow.

As owner, a sidecar mutates through two mechanisms:

  • Hot path — pushed rules applied inline as traffic flows (see Rules).
  • Replay / origination — the ordered mutation ops carried on sidecar_send / invoke_adapter, applied when an agent re-sends or originates a message (see Mutation operations).
Rules

Proxy rules are protocol-coupled, so a sidecar applies the rules relevant to its own flows; HTTP/WS traffic not delegated to a sidecar is handled by sectool. Sectool pushes the authoritative ordered rule list via sync_rules, one push at a time per sidecar and always carrying the current list; the sidecar replaces its local cache atomically and applies find/replace on its hot path, exactly as the native proxy does. Sectool filters the list per sidecar, sending only rules with an empty adapter scope or matching the sidecar's name.


Go SDK

Quick start
package main

import (
    "context"
    "log"
    "os"
    "path/filepath"

    "github.com/go-appsec/toolbox/sidecar"
    "github.com/go-appsec/toolbox/sidecar/wire"
)

type myHandler struct{ sidecar.BaseHandler }

func (h *myHandler) OnStreamDeliver(p wire.StreamWriteParams) ([]wire.StreamWrite, error) {
    // Parse protocol frames from p.Data and emit flows...
    return nil, nil
}

func main() {
    reg := sidecar.Registration{
        Name:      "my-protocol",
        Protocols: []string{"myproto/1"},
        Capabilities: wire.Capabilities{
            EarlyClaims: []wire.EarlyClaim{{
                PortRange:        wire.PortRange{Low: 9443, High: 9443},
                MagicBytesPrefix: "bXlwcm90bw==", // base64 of magic bytes
            }},
        },
    }

    ctx := context.Background()
    home, _ := os.UserHomeDir()
    sock := filepath.Join(home, ".sectool", "sidecar.sock")
    conn, err := sidecar.Dial(ctx, sock, reg)
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close()

    log.Fatal(conn.Serve(ctx, &myHandler{}))
}
Registration

sidecar.Dial(ctx, addr, reg) connects and registers, with the connect and handshake bounded by ctx. The Registration struct declares your adapter's identity and capabilities:

reg := sidecar.Registration{
    Name:            "mqtt-adapter",
    ProtocolVersion: wire.ProtocolVersion{},       // zero defaults to the SDK's compiled contract version
    Protocols:       []string{"mqtt/3.1.1"},
    Capabilities:    wire.Capabilities{...},
    MCPTools:        []wire.MCPTool{...},           // optional custom MCP tools
}

conn, err := sidecar.Dial(ctx, addr, reg)
if errors.Is(err, sidecar.ErrVersionUnsupported) {
    // built against an incompatible toolbox (wrong major, or newer minor); rebuild against the running `sectool`
}
  • Name — unique adapter identifier (cannot be sectool, the core process)
  • ProtocolVersion — the gating {major, minor} contract version; leave zero to default to the SDK's compiled version
  • Protocols — protocol identifiers the adapter provides
  • Capabilities — connection-handling seams to claim (see Capabilities)
  • MCPTools — optional custom MCP tools the sidecar exposes to agents
  • InstanceID — optional UUID for reconnect state recovery; Resume requests reattachment of in-flight flow metadata
Serving
err := conn.Serve(ctx, &myHandler{})

Serve blocks until context cancellation or remote close. Returns ctx.Err() on cancellation, nil on clean shutdown.

Handler interface

Implement sidecar.Handler to receive inbound events. Embed BaseHandler for no-op defaults and override only what you need:

Method When called Return
OnShutdown(drainSeconds int) Sectool requests graceful close
OnStreamOpen(params) A claimed stream opens (early or upgrade claim) []wire.StreamWrite for initial response bytes
OnStreamDeliver(params) Inbound bytes arrive on a stream []wire.StreamWrite to write back (possibly to a different stream)
OnStreamEnded(params) A stream closes (peer disconnect, scope policy, shutdown)
OnClaimProbe(params) Probe-based early claim asks if the connection is this protocol (bool, error), true claims it
OnSidecarSend(params) Agent replays or originates a message through this adapter (wire.SidecarSendResult, error)
OnInvokeTool(params) An MCP client calls one of the sidecar's registered tools (wire.InvokeToolResult, error)
Stream events

OnStreamDeliver receives raw transport bytes, not aligned to protocol frame boundaries. One frame may span several deliveries; several may arrive in one chunk. Use sidecar.Reassembler to buffer and extract complete frames:

var reasm sidecar.Reassembler

func (h *myHandler) OnStreamDeliver(p wire.StreamWriteParams) ([]wire.StreamWrite, error) {
    reasm.Append(p.Data)
    for frame, ok := reasm.Next(h.splitFrame); ok; frame, ok = reasm.Next(h.splitFrame) {
        // Process complete frame...
    }
    return nil, nil
}

func (h *myHandler) splitFrame(buf []byte) (n int, ok bool) {
    // Return frame length and true when a complete frame is buffered.
}

The returned writes tell sectool what bytes to write back. A write may target a different stream_id than the event arrived on; that is how client data is forwarded upstream. Returned writes stay ordered against proactive StreamWrite calls on the same stream. sidecar.Forward(streamID, bytes) builds a single-target writes slice:

func (h *myHandler) OnStreamDeliver(p wire.StreamWriteParams) ([]wire.StreamWrite, error) {
    return sidecar.Forward(upstreamID, frameBytes), nil
}
StreamConn and StreamRouter

When an adapter wraps a library that expects a blocking net.Conn (a TLS/Noise handshake, a relay protocol), use StreamRouter instead of the callbacks. It turns a claim's stream events into Accept-able StreamConns, each a net.Conn whose Read returns delivered bytes and whose Write/Close emit stream_write/close_stream. Embed the router in your handler; it supplies the three stream callbacks and you override the rest:

type myHandler struct {
    *sidecar.StreamRouter
}

conn, _ := sidecar.Dial(ctx, addr, reg)
h := &myHandler{StreamRouter: sidecar.NewStreamRouter(conn)}
go conn.Serve(ctx, h)

for {
    sc, err := h.Accept(ctx) // pump in a loop; a full backlog backpressures stream_open
    if err != nil {
        return err
    }
    go handle(conn, sc) // sc is a net.Conn: blocking Read/Write, deadlines
}

Inside the handler, read frames and emit what you observe with conn.PushFlow (see Emitting flows). Most protocol libraries want frame boundaries, not raw bytes, so layer a Reassembler over StreamConn.Read:

func handle(conn *sidecar.Conn, sc *sidecar.StreamConn) {
    defer sc.Close()
    var reasm sidecar.Reassembler
    b := make([]byte, 4096)
    for {
        n, err := sc.Read(b)
        if err != nil {
            return
        }
        reasm.Append(b[:n])
        for frame, ok := reasm.Next(splitFrame); ok; frame, ok = reasm.Next(splitFrame) {
            conn.PushFlow(ctx, toFlow(frame))
        }
    }
}

Close is graceful (after queued writes); call conn.CloseStream(id, reason, true) to abort. A successful Write means the bytes were accepted for delivery, not that they reached the socket.

To bridge a dialed upstream through the same router, use router.DialUpstream instead of conn.DialUpstream: it dials, then returns a StreamConn routed by the router, so the upstream half reads and writes as an ordinary net.Conn alongside the accepted client stream. A dialed stream is not queued for Accept; stream_ended or Close tears it down through the same path as an accepted stream.

up, err := router.DialUpstream(ctx, wire.DialUpstreamParams{Host: "api.example.com", Port: 443})
if err != nil {
    return err
}
defer up.Close()
Emitting flows
PushFlow

Emit a captured exchange. Leave flow_id empty on first emission; sectool assigns it:

flowID, err := conn.PushFlow(ctx, wire.Flow{
    ProtocolTag: "mqtt/3.1.1",
    Direction:   "client_to_server",
    Request: &wire.FlowMessage{
        Method:  "PUBLISH",
        Path:    "/sensors/temp",
        Headers: []wire.Header{{Name: "QoS", Value: "1"}},
        Body:    payload,
    },
})
Two-phase completion

Emit the request side first, then attach the response with the returned flow_id (see Flow model):

flowID, _ := conn.PushFlow(ctx, wire.Flow{Request: req})
// Later:
conn.CompleteFlow(ctx, flowID, resp, time.Now())
Rule mutations

When a rule mutates a message on the hot path, store a single flow (the mutated one) via PushFlow, mirroring the native proxy. Do not emit a separate pre-mutation copy.

Streams and sessions

Use parent-child flows for long-lived exchanges (see Flow model):

// Open stream (parent), returns the stream's flow_id
streamID, _ := conn.PushFlow(ctx, wire.Flow{
    ProtocolTag: "myproto/stream",
    Request:     &wire.FlowMessage{Method: "STREAM_OPEN"},
})

// Emit chunks (children) in order
conn.PushFlow(ctx, wire.Flow{
    ParentFlowID: streamID,
    Direction:    "server_to_client",
    Request:      &wire.FlowMessage{Body: chunk},
})

// Close (two-phase re-emit of the parent)
conn.CompleteFlow(ctx, streamID, nil, time.Now())

Session/tunnel envelopes follow the same pattern with Direction: "bidirectional" and Method: "TUNNEL".

Non-decodable bodies

When sectool can't natively decode the wire body, supply both the logical and raw forms; unmutated replay resends body_raw, a mutated body re-encodes through body_codec (see Flow model):

Request: &wire.FlowMessage{
    Body:    decodedPayload, // logical body tools operate on
    BodyRaw: wireBytes,      // verbatim wire bytes for unmutated replay
    BodyCodec: &wire.BodyCodec{
        Transforms:  []string{"decrypt-noise", "decompress-zstd"},
        ContentType: "application/octet-stream",
    },
}
Rules

The SDK maintains a hot-path RuleCache, refreshed atomically on each sync_rules push. Access via conn.Rules(); scoped rules for other adapters are filtered out automatically (see Rules).

mutatedBody, firedRules := conn.Rules().ApplyBody(body, wire.RuleTypeRequestBody)
mutatedHeaders, firedRules := conn.Rules().ApplyHeaders(headers, wire.RuleTypeRequestHeader) // case-insensitive
mutatedPayload, firedRules := conn.Rules().ApplyWS(payload, wire.RuleTypeWSToServer)
Upstream connections

Request that sectool open a TCP connection on your behalf:

upstreamID, err := conn.DialUpstream(ctx, wire.DialUpstreamParams{
    Host: "api.example.com",
    Port: 443,
    TLS:  &wire.DialUpstreamTLS{Enabled: true, SNI: "api.example.com"},
})

Sectool applies scope policy and records the dial. Bytes flow through the same event model: inbound via OnStreamDeliver, outbound via returned writes. Omit Host/Port and set ParentFlowID to dial that flow's original destination.

Cross-adapter invocation

Route an outbound message through another adapter's injection target:

result, err := conn.InvokeAdapter(ctx, wire.InvokeAdapterParams{
    Adapter: "http/1.1",
    Target:  json.RawMessage(`{"url":"https://api.example.com/key"}`),
    Payload: json.RawMessage(`{"method":"GET","headers":{}}`),
})

The reserved destination sectool routes to native HTTP origination.

Invoking sectool core tools

Query or drive core sectool tools by name from within a handler or tool implementation:

result, err := conn.CoreInvoke(ctx, "proxy_poll", map[string]any{"mode": "flows", "limit": 10})

result.Content is the tool's markdown output. core_invoke reaches the same core tools agents call — both reads and writes. Internal tools are not invocable.

Replay and origination (OnSidecarSend)

When an agent calls replay_send on a flow captured by your adapter, sectool routes it to OnSidecarSend. The params carry the source flow inline, so you have body, body_raw, and body_codec without a round-trip:

func (h *myHandler) OnSidecarSend(p wire.SidecarSendParams) (wire.SidecarSendResult, error) {
    msg := wire.FlowMessage{
        Method:  p.Flow.Request.Method,
        Path:    p.Flow.Request.Path,
        Headers: slices.Clone(p.Flow.Request.Headers),
        Body:    p.Flow.Request.Body,
    }
    if err := sidecar.ApplyMutations(&msg, p.Mutations); err != nil {
        return wire.SidecarSendResult{}, err
    }
    // Re-encode and send per adapter configuration; emit resulting flow(s) via
    // conn.PushFlow() with parent_flow_id set to p.FlowID
    return wire.SidecarSendResult{NewFlowIDs: []string{newFlowID}}, nil
}

Set the result flow's parent_flow_id to the source (p.FlowID); sectool then files it into replay history automatically, like a native replay.

Mutation operations

sidecar.ApplyMutations(&msg, mutations) applies an ordered list of {op, name, value} operations in array order:

op name value
set_header / remove_header header name new value (omit for remove)
set_json / remove_json dot/bracket path JSON value (omit for remove)
set_form / remove_form form field name new value (omit for remove)
set_query / remove_query query param name new value (omit for remove)
method / path / query (unused) full replacement string
body (unused) full body replacement

The op names are exported as constants in sidecar/wire (wire.OpSetHeader, wire.OpSetQuery, …). set_query/remove_query edit the raw query in place, preserving parameter order and percent-encoding.

As the flow's sole owner (see Mutation ownership), the sidecar applies the ops once, then re-encodes and re-establishes any protocol binding (signatures, framing, compression). The github.com/go-appsec/toolbox/pkg/mutate helpers (JSON, Form, Query, ReplaceCaseInsensitive, header render/parse) back ApplyMutations and the RuleCache, and are available directly when you need to mutate outside them.

Custom MCP tools

Register protocol-specific tools that agents discover through the normal MCP tools/list:

MCPTools: []wire.MCPTool{
    {
        Name:        "mqtt_subscribe",
        Description: "Subscribe to an MQTT topic and capture messages as flows",
        InputSchema: json.RawMessage(`{"type":"object","properties":{"topic":{"type":"string"}}}`),
    },
}

Invocation reaches OnInvokeTool with sectool-validated arguments. The handler may read state via CoreInvoke and emit flows via PushFlow. Return the result as a JSON object in InvokeToolResult.Result (the advertised output schema is an object; wrap plain-text results in a field, e.g. {"summary":"..."}); sectool returns it to the client as structured content and renders the text fallback from it.

Proactive stream operations

Two actions outside of event responses:

conn.CloseStream(streamID, "session ended", false)  // close after the writes already sent
conn.CloseStream(streamID, "stale", true)           // abort now, dropping queued writes
conn.StreamWrite(streamID, keepaliveBytes)          // write without a triggering event

StreamWrite calls are applied in send order and stay ordered against writes returned from stream events.

Logging and metrics
conn.Log("info", "connected to broker", map[string]any{"broker": "mqtt.example.com"})
conn.ReportMetrics(map[string]int64{"frames_parsed": 150}, map[string]float64{"buffer_bytes": 4096})
Error handling

The SDK surfaces JSON-RPC 2.0 errors as *wire.Error ({Code, Message, Data}). Sectool-specific codes occupy -33000..-33999; Dial maps -33001 to ErrVersionUnsupported. See Error object for the full code table.


JSON-RPC 2.0 wire protocol

For implementing a sidecar without the Go SDK. Every field name below is a JSON tag from the shared sidecar/wire package, which both peers import, so the two ends encode byte-identical structures. Read Concepts first for the semantics.

Framing

A single connection carries a sequence of length-prefixed messages:

  • 4 bytes — big-endian uint32 payload length (counts the JSON bytes only).
  • N bytes — the JSON-RPC 2.0 message.

No delimiter, no trailing newline. Frames are bounded to 128 MiB on both read and write; a frame exceeding it errors with -33201 (a write is refused, an oversized read prefix is rejected before allocating). max_body_bytes governs only what history retains, never what is forwarded.

Message envelope
{ "jsonrpc": "2.0", "id": 1, "method": "push_flow", "params": { ... } }
Field Type Notes
jsonrpc string always "2.0"
id number unsigned integer, present on requests and responses
method string present on requests and notifications
params object method parameters
result object success response payload
error object error response (see below)

Message kind is discriminated by presence:

  • Request — has id and method (expects a response).
  • Response — has id, no method (carries result or error).
  • Notification — has method, no id (fire-and-forget).

Both peers are symmetric: either may issue requests and notifications. id is an incrementing unsigned integer, unique per outstanding request per direction, echoed verbatim in the response (a quoted-numeric echo, e.g. "1", is also accepted). The reader must dispatch each inbound request to a separate task, so a handler awaiting a nested request never blocks the read loop and deadlocks the connection. Malformed frames are silently skipped, not answered.

Binary fields (body, body_raw, stream/probe data, magic_bytes_prefix) use standard-alphabet padded base64. Fields typed as raw JSON (schemas, annotations, structured tool output, and the adapter-validated target/payload/params) are embedded verbatim, not re-encoded; each method's params note which applies.

Error object
{ "code": -33001, "message": "contract major mismatch: `sectool` 1, sidecar 2",
  "data": { "adapter": "my-protocol" } }

data (all optional): adapter, conflict_adapter, flow_id, stream_id.

Standard JSON-RPC codes -32601 (method not found) and -32603 (internal) apply. Sectool-specific codes occupy the reserved range -33000..-33999:

Code Meaning
-33000 Registration rejected
-33001 Version unsupported (wrong major or newer minor)
-33002 Duplicate registration
-33003 Capability conflict
-33004 Tool name conflict
-33005 Not registered
-33100 Flow emission rejected
-33101 core_invoke validation rejected
-33102 Rule shape rejected
-33200 Framing violation
-33201 Oversized message
-33202 Unknown stream_id
-33203 claim_probe fault (probe errored)
-33299 Transport internal
-33300 dial_upstream scope rejection
-33301 dial_upstream dial failed
-33302 dial_upstream TLS failed
-33400 Unknown destination adapter
-33401 Destination missing injection_target
-33402 Native origination / send failed

A claim_probe returning {"claim": false} is normal control flow, not an error.

Method catalog
Method Direction Kind
register sidecar → sectool request
push_flow sidecar → sectool request
core_invoke sidecar → sectool request
dial_upstream sidecar → sectool request
invoke_adapter sidecar → sectool request
log sidecar → sectool notification
report_metrics sidecar → sectool notification
close_stream sidecar → sectool notification
stream_write sidecar → sectool notification
sync_rules sectool → sidecar request
sidecar_send sectool → sidecar request
invoke_tool sectool → sidecar request
stream_open sectool → sidecar request
stream_deliver sectool → sidecar request
claim_probe sectool → sidecar request
shutdown sectool → sidecar request
stream_ended sectool → sidecar notification
ping / pong either direction notification (or request)

Messages are ordered per concern, not globally. All of a stream's bytes travel as stream_write on one ordered path, so they reach its socket in the order the sidecar sent them, and close_stream takes its place in that same order. log and report_metrics keep their order against each other. sync_rules pushes apply in the order sectool sent them. Everything else, including ping/pong and the remaining request/response traffic, is handled concurrently.

Sectool queues each stream's pending writes. A stream event's writes are paced by the queue, so a slow client throttles the stream feeding it. A proactive stream_write cannot be paced without stalling other streams, so outrunning a stalled client by more than the queue depth closes that stream, reported as the usual stream_ended.

Methods

Params and results below list JSON field names. Reused shapes (Flow, FlowMessage, Rule, Capabilities, …) are defined under Shared structs.

register (sidecar → sectool)

Issued exactly once, first message on the connection.

params: name (string, required), protocol_version ({major, minor}, required), protocols ([string], optional), capabilities (object, optional), mcp_tools ([MCPTool], optional), instance_id (string UUID, optional), resume (bool, optional).

result: protocol_version (the effective {major, minor}), server_time (RFC3339Nano string).

Rejected with -33001 when the major differs (any direction) or the sidecar's minor is newer than sectool's. Otherwise the session runs at the sidecar's (≤ sectool) minor, echoed in the result's protocol_version.

push_flow (sidecar → sectool)

params: a bare Flow object (not wrapped). Empty flow_id is first emission; set flow_id to target an existing flow for two-phase completion or teardown. See Flow model.

result: { "flow_id": string }.

core_invoke (sidecar → sectool)

Invokes a core sectool MCP tool by name, reusing the same handlers agents call. Internal tools are not invocable.

params: tool (string, a core MCP tool name, e.g. proxy_poll, proxy_respond_add), params (raw JSON, that tool's parameters).

result: content (string, the tool's markdown), is_error (bool, optional).

dial_upstream (sidecar → sectool)

params: host (string, optional), port (int, optional), tls ({enabled, sni?, alpn?, skip_verify?}, optional), parent_flow_id (string, optional, supplies the default destination and links the dial). Omitting host/port dials parent_flow_id's original destination.

result: { "stream_id": string }, or a -3330x error on scope/dial/TLS failure. Bytes then flow via stream_deliver events and stream_write. stream_deliver for the dialed stream is not ordered against this result and may arrive first, so a consumer must buffer deliveries for a stream it has not yet registered (StreamRouter does this).

invoke_adapter (sidecar → sectool)

params: adapter (string, required, destination with an injection_target), target (raw JSON, validated by the destination adapter), payload (raw JSON, likewise), mutations ([Mutation], optional), wait_for_response (bool, optional, default true).

result: new_flow_ids ([string]), response (FlowMessage, when waited). Destination sectool originates via the native HTTP send path; target/payload then mirror request_send ({url, method, headers, body, follow_redirects, force}).

log (sidecar → sectool, notification)

params: level (string, optional), message (string), fields (object, optional).

report_metrics (sidecar → sectool, notification)

params: counters ({name: int64}), gauges ({name: number}).

close_stream (sidecar → sectool, notification)

params: stream_id (string), reason (string, optional), abort (bool, optional). Proactively closes a client-facing or dialed upstream stream after the writes already queued for it. Set abort to close immediately and drop them.

stream_write (sidecar → sectool, notification)

params: stream_id (string), data (standard-alphabet padded base64). Proactive write for keepalives and for output produced with no triggering event. As a notification it is fire-and-forget: an unknown stream_id is logged and dropped by sectool (-33202) and is not reported back to the sidecar.

sync_rules (sectool → sidecar)

params: rules ([Rule], the full ordered list to apply). The sidecar replaces its cache wholesale; an empty list clears it.

result: ack (bool). On an unsupported rule shape, return -33102 naming the rule_id; the sidecar keeps its previous rules.

Pushed once at registration (before the register result, even when the list is empty, so a reconnecting sidecar never keeps a stale cache) and again on every change. Sectool waits for each ack before sending the next push, so a later snapshot never overwrites a newer one.

sidecar_send (sectool → sidecar)

The method behind agent replay_send and the destination side of invoke_adapter. Never called by agents directly.

params: flow_id (string, optional, set to replay), flow (Flow, the resolved source passed inline on replay), destination (string, optional scheme://host[:port] routing override), target / payload (raw JSON, for origination), mutations ([Mutation]), follow_redirects (bool, optional), force (bool, optional), wait_for_response (bool, optional, default true), stream_strategy (string, optional — per_chunk (default) replays a stream's children in order, collapsed merges them; adapters whose protocol forbids reordering reject collapsed).

result: new_flow_ids ([string]), writes ([StreamWrite], optional first outbound bytes), response (FlowMessage, when waited).

invoke_tool (sectool → sidecar)

params: name (string, a registered tool), arguments (raw JSON, sectool-validated against input_schema).

result: result (a raw JSON object, the tool result; sectool derives the MCP text fallback from it), is_error (bool, optional).

stream_open (sectool → sidecar)

params: stream_id (string), host, path, peer_addr (strings, optional), plus request_flow_id (string) and request_headers ([Header]), present only for an upgrade_claim, absent for early_claim.

result: { "wrote_to": [string] } (usually empty; the client speaks first).

stream_deliver (sectool → sidecar)

params: stream_id (string), data (standard-alphabet padded base64, a raw transport chunk not frame-aligned; see Stream events for reassembly).

result: { "wrote_to": [string] }, the stream ids the sidecar wrote to while handling the event. The bytes themselves travel as stream_write notifications sent before the response, keeping them ordered against proactive writes; wrote_to lets sectool pace those streams. Sectool awaits this response before the next chunk (per-stream ordering). The sidecar may nest dial_upstream, core_invoke, or push_flow.

claim_probe (sectool → sidecar)

params: host, port, peer_addr, sni (optional), data (standard-alphabet padded base64, buffered opening bytes).

result: { "claim": bool }. True takes the connection; false declines to next probe or HTTP fallthrough. See Capabilities.

shutdown (sectool → sidecar)

params: drain_seconds (int). result: { "ack": true }. The sidecar finishes in-flight work, emits a final report_metrics, and closes.

stream_ended (sectool → sidecar, notification)

params: stream_id (string), reason (string, optional). The sidecar reacts by closing any paired stream.

ping / pong (either direction)

A ping request (has id) is answered with an empty {} result. A ping notification (no id) is answered with a pong notification. Sectool records a received pong as liveness, and marks a sidecar unhealthy after enough consecutive intervals without a reply.

Shared structs
Flow
{
  "flow_id": "", "adapter": "", "protocol_tag": "mqtt/3.1.1",
  "direction": "client_to_server", "parent_flow_id": "",
  "scheme": "", "port": 0,
  "request":  { /* FlowMessage */ },
  "response": { /* FlowMessage */ },
  "started_at": "2026-07-02T09:30:00Z", "completed_at": "0001-01-01T00:00:00Z",
  "annotations": {}
}

All fields omitempty. annotations is a free-form object the sidecar owns; sectool stores it verbatim. Replay classification is not annotation-driven: sectool files a pushed flow into replay history when its parent_flow_id is the source of an in-flight replay (see Replay and origination).

Timestamps are RFC 3339 strings. An empty flow_id is first emission (sectool assigns); set flow_id to re-target an existing flow for two-phase completion or teardown.

FlowMessage
{
  "method": "PUBLISH", "path": "/sensors/temp", "query": "",
  "status_code": 0, "status_text": "",
  "headers": [ { "name": "QoS", "value": "1" } ],
  "body": "<base64>", "body_raw": "<base64>",
  "body_codec": { "transforms": ["decrypt-noise"], "content_type": "application/octet-stream" }
}

body is the logical payload every tool operates on; encoded as standard-alphabet padded base64 on the wire. body_raw (also base64) and body_codec carry the verbatim wire form when body is not natively decodable — see Flow model. status_code / status_text are the response side's outcome.

Header

{ "name": string, "value": string }, an ordered array, not a map; duplicates and order are preserved.

StreamWrite

{ "stream_id": string, "data": "<base64>" }, bytes for sectool to write. stream_id may differ from the stream the event arrived on; that is how client bytes are forwarded upstream.

Rule
{ "rule_id": "r1", "type": "request_body", "label": "", "is_regex": false,
  "find": "foo", "replace": "bar", "adapter": "" }

typerequest_header, request_body, response_header, response_body, ws:to-server, ws:to-client, ws:both. An empty adapter applies to every adapter; otherwise it names the owning sidecar.

Capabilities
{
  "early_claims": [{
    "port_range": { "low": 9443, "high": 9443 },
    "tls": {
      "terminate": true, "sni_match": "mqtt.example.com",
      "cert": {
        "dns_names": ["alt.example.com"], "ip_addresses": ["10.0.0.1"],
        "uris": ["spiffe://example.com/svc"], "emails": [], "common_name": ""
      }
    },
    "magic_bytes_prefix": "<base64>", "host_match": "",
    "probe": false, "probe_max_bytes": 0
  }],
  "upgrade_claims": [{
    "host_pattern": "example\\.com", "path_pattern": "/ws/custom",
    "upgrade_signal": "http_101", "method_set": ["GET"]
  }],
  "injection_targets": [{ "target_schema": { /* JSON Schema */ } }]
}

upgrade_signalhttp_101, connect; empty means http_101. Each seam is a list; omit or leave empty the ones you don't claim, and declare more than one entry to claim multiple entry points. magic_bytes_prefix is standard-alphabet padded base64, and port_range { "low": 0, "high": 0 } matches any port. host_pattern and path_pattern are whole-value RE2 patterns; see Early claim matching and Upgrade claim matching for how each matcher applies per seam.

Mutation

{ "op": string, "name": string, "value": string }, applied in array order. See the op table.

MCPTool

{ "name": string, "description": string, "input_schema": <JSON Schema>, "annotations": <JSON> }.

Documentation

Index

Constants

This section is empty.

Variables

View Source
var ErrRouterClosed = errors.New("sidecar: stream router closed")

ErrRouterClosed is returned by Accept once the underlying conn has closed.

View Source
var ErrVersionUnsupported = errors.New("sidecar: protocol version unsupported")

ErrVersionUnsupported is returned by Dial when sectool rejects the registration because the sidecar's contract version is unsupported.

Functions

func ApplyMutations

func ApplyMutations(msg *wire.FlowMessage, muts []wire.Mutation) error

ApplyMutations applies the ordered mutation ops to msg in place. Ops run in slice order so later edits observe earlier ones. An unknown op or a body edit against an incompatible body errors.

func Forward

func Forward(toStreamID string, data []byte) []wire.StreamWrite

Forward builds the writes for a stream event Response that send data out a paired stream.

Types

type BaseHandler

type BaseHandler struct{}

BaseHandler provides no-op/decline defaults for every Handler callback. Embed it and override only the callbacks the adapter supports.

func (BaseHandler) OnClaimProbe

func (BaseHandler) OnClaimProbe(wire.ClaimProbeParams) (bool, error)

func (BaseHandler) OnInvokeTool

func (BaseHandler) OnShutdown

func (BaseHandler) OnShutdown(int)

func (BaseHandler) OnSidecarSend

func (BaseHandler) OnStreamDeliver

func (BaseHandler) OnStreamDeliver(wire.StreamWriteParams) ([]wire.StreamWrite, error)

func (BaseHandler) OnStreamEnded

func (BaseHandler) OnStreamEnded(wire.StreamEndedParams)

func (BaseHandler) OnStreamOpen

type Conn

type Conn struct {
	// contains filtered or unexported fields
}

Conn is a registered connection to sectool.

func Dial

func Dial(ctx context.Context, addr string, reg Registration) (*Conn, error)

Dial connects to sectool at addr, performs the register handshake, and returns the established connection. The connect and handshake are bounded by ctx, capped at registerTimeout.

func (*Conn) Close

func (c *Conn) Close() error

Close terminates the connection.

func (*Conn) CloseStream

func (c *Conn) CloseStream(streamID, reason string, abort bool) error

CloseStream proactively closes an open stream (client-facing or a dialed upstream). It closes after the writes already sent for that stream, or immediately when abort drops them. It is the companion to the stream events delivered to a Handler.

func (*Conn) CompleteFlow

func (c *Conn) CompleteFlow(ctx context.Context, flowID string, resp *wire.FlowMessage, completedAt time.Time) error

CompleteFlow attaches a late response and/or completion to flowID: the two-phase form for deferred responses and session/stream teardown.

func (*Conn) CoreInvoke

func (c *Conn) CoreInvoke(ctx context.Context, tool string, params any) (wire.CoreInvokeResult, error)

CoreInvoke invokes a core MCP tool by name and returns its result.

func (*Conn) DialUpstream

func (c *Conn) DialUpstream(ctx context.Context, p wire.DialUpstreamParams) (string, error)

DialUpstream asks sectool to open an upstream TCP connection (subject to scope policy, optionally TLS-terminated) and bridge it as a new stream, returning the upstream stream_id.

func (*Conn) InvokeAdapter

InvokeAdapter routes an outbound message through another registered adapter's injection_target and returns the flows it produced. Scope policy and the destination adapter's own validation apply.

func (*Conn) Log

func (c *Conn) Log(level, message string, fields map[string]any) error

Log emits a structured diagnostic log line.

func (*Conn) PushFlow

func (c *Conn) PushFlow(ctx context.Context, flow wire.Flow) (string, error)

PushFlow emits a captured flow and returns the flow_id sectool assigned. Leave flow.FlowID empty to store a new flow, or set it to re-target an existing flow. A returned empty flow_id with no error means the operator's capture filter excluded the flow; it was not stored and cannot be re-targeted.

func (*Conn) ReportMetrics

func (c *Conn) ReportMetrics(counters map[string]int64, gauges map[string]float64) error

ReportMetrics emits counter and gauge samples.

func (*Conn) Rules

func (c *Conn) Rules() *RuleCache

Rules returns the hot-path rule cache, kept current by sectool's sync_rules pushes.

func (*Conn) Serve

func (c *Conn) Serve(ctx context.Context, h Handler) error

Serve installs the inbound handler and blocks until ctx is cancelled or the connection closes (e.g. after sectool shutdown). Returns ctx.Err() on cancellation, nil on a clean remote close.

func (*Conn) SetHandler added in v0.1.19

func (c *Conn) SetHandler(h Handler)

SetHandler installs the inbound handler synchronously. Serve calls this before it blocks; call it directly when the handler must be active before Serve starts. A nil handler installs the no-op BaseHandler.

func (*Conn) StreamWrite

func (c *Conn) StreamWrite(streamID string, data []byte) error

StreamWrite proactively writes bytes to an open stream without a triggering event, for keepalives and output produced by a synchronous state machine. Bytes reach the stream in send order, including against writes returned from stream events.

type Handler

type Handler interface {

	// OnShutdown is invoked when sectool requests a graceful close. The SDK
	// acknowledges automatically after this returns, so the sidecar should
	// finish in-flight work here before returning.
	OnShutdown(drainSeconds int)

	// OnStreamOpen and OnStreamDeliver receive the claimed stream's events and
	// return bytes for sectool to write back (possibly to a different stream_id).
	// Inbound chunks are raw transport bytes, not aligned to protocol frames; use
	// Reassembler to accumulate complete frames. OnStreamEnded reports teardown and
	// runs concurrently, unordered against other stream events.
	OnStreamOpen(wire.StreamOpenParams) ([]wire.StreamWrite, error)
	OnStreamDeliver(wire.StreamWriteParams) ([]wire.StreamWrite, error)
	OnStreamEnded(wire.StreamEndedParams)

	// OnClaimProbe decides a probe-based early_claim: true takes the connection,
	// false declines so sectool tries the next claim (or falls through to HTTP).
	// An error is reported as a probe fault and declines.
	OnClaimProbe(wire.ClaimProbeParams) (bool, error)

	// OnSidecarSend serves both replay of the adapter's own flows and origination
	// (injection_target), returning the produced flow ids.
	OnSidecarSend(wire.SidecarSendParams) (wire.SidecarSendResult, error)

	// OnInvokeTool handles a validated MCP tool call (Registration.MCPTools)
	// delegated from a client and returns the result content. The handler may
	// read sectool state (Conn.CoreInvoke) and emit flows (Conn.PushFlow).
	OnInvokeTool(wire.InvokeToolParams) (wire.InvokeToolResult, error)
}

Handler is the sidecar's inbound callback surface. Embed BaseHandler to get no-op defaults and override only the callbacks the adapter implements.

type Reassembler

type Reassembler struct {
	// contains filtered or unexported fields
}

Reassembler accumulates stream_deliver chunks until a complete protocol frame is buffered.

func (*Reassembler) Append

func (r *Reassembler) Append(data []byte)

Append adds a delivered chunk to the buffer.

func (*Reassembler) Buffered

func (r *Reassembler) Buffered() int

Buffered reports the number of bytes held but not yet drained.

func (*Reassembler) Next

func (r *Reassembler) Next(split func(buf []byte) (n int, ok bool)) ([]byte, bool)

Next returns the leading complete frame and true, or nil and false when no whole frame is buffered. split reports the leading frame's length and whether a whole frame is present.

type Registration

type Registration struct {
	Name      string
	Protocols []string
	// Capabilities may declare multiple claims of each kind.
	Capabilities wire.Capabilities
	MCPTools     []wire.MCPTool
	InstanceID   string
	Resume       bool
	// ProtocolVersion defaults to the SDK's compiled contract version when left zero.
	ProtocolVersion wire.ProtocolVersion
}

Registration declares the adapter's identity and capabilities for the register handshake.

type RuleCache

type RuleCache struct {
	// contains filtered or unexported fields
}

RuleCache holds the rules sectool pushes and applies the ones scoped to this adapter on the hot path. Safe for concurrent use.

func (*RuleCache) ApplyBody

func (c *RuleCache) ApplyBody(body []byte, ruleType string) ([]byte, []string)

ApplyBody applies body rules of the given type (request_body or response_body) and returns the result plus the ids of rules that changed it. Matching is case-sensitive.

func (*RuleCache) ApplyHeaders

func (c *RuleCache) ApplyHeaders(headers []wire.Header, ruleType string) ([]wire.Header, []string)

ApplyHeaders applies header rules of the given type (request_header or response_header) to the header list, returning the result and the fired rule ids. Matching is case-insensitive, mirroring the in-process proxy.

func (*RuleCache) ApplyWS

func (c *RuleCache) ApplyWS(payload []byte, direction string) ([]byte, []string)

ApplyWS applies WebSocket rules for the given direction (ws:to-server or ws:to-client), including ws:both, to a frame payload.

type StreamConn added in v0.2.0

type StreamConn struct {
	// contains filtered or unexported fields
}

StreamConn is a net.Conn over one claimed byte stream, so a blocking library state machine can run unmodified atop the async stream events. Read returns bytes delivered by stream_deliver. Write and Close emit stream_write and close_stream. Obtain one from a StreamRouter.

func (*StreamConn) Close added in v0.2.0

func (c *StreamConn) Close() error

Close closes the stream gracefully after queued writes. Use Conn.CloseStream with abort to drop them instead.

func (*StreamConn) LocalAddr added in v0.2.0

func (c *StreamConn) LocalAddr() net.Addr

LocalAddr reports a synthetic local address.

func (*StreamConn) Open added in v0.2.0

func (c *StreamConn) Open() wire.StreamOpenParams

Open returns the stream_open params: host, path, peer, and (upgrade claims only) the triggering request's flow id and headers.

func (*StreamConn) Read added in v0.2.0

func (c *StreamConn) Read(p []byte) (int, error)

Read drains delivered bytes, blocking until data arrives, the stream ends, the conn is closed, or the read deadline fires.

func (*StreamConn) RemoteAddr added in v0.2.0

func (c *StreamConn) RemoteAddr() net.Addr

RemoteAddr reports the connecting peer's address from stream_open.

func (*StreamConn) SetDeadline added in v0.2.0

func (c *StreamConn) SetDeadline(t time.Time) error

SetDeadline sets both the read and write deadlines.

func (*StreamConn) SetReadDeadline added in v0.2.0

func (c *StreamConn) SetReadDeadline(t time.Time) error

SetReadDeadline sets the deadline for future Reads; a zero time clears it.

func (*StreamConn) SetWriteDeadline added in v0.2.0

func (c *StreamConn) SetWriteDeadline(t time.Time) error

SetWriteDeadline sets the deadline for future Writes; a zero time clears it.

func (*StreamConn) StreamID added in v0.2.0

func (c *StreamConn) StreamID() string

StreamID returns the stream's identifier.

func (*StreamConn) Write added in v0.2.0

func (c *StreamConn) Write(p []byte) (int, error)

Write sends bytes out the stream via stream_write, sharing the ordered write path with event-driven writes. A nil error means the bytes were accepted for delivery, not that they reached the socket.

type StreamRouter added in v0.2.0

type StreamRouter struct {
	BaseHandler
	// contains filtered or unexported fields
}

StreamRouter turns a claim's stream events into Accept-able StreamConns, so an adapter writes ordinary blocking net.Conn code instead of the stream callbacks. Embed it in a Handler and it supplies OnStreamOpen/OnStreamDeliver/OnStreamEnded; override the other callbacks (OnSidecarSend, OnInvokeTool, ...) as needed.

func NewStreamRouter added in v0.2.0

func NewStreamRouter(conn *Conn) *StreamRouter

NewStreamRouter returns a router that opens StreamConns over conn.

func (*StreamRouter) Accept added in v0.2.0

func (r *StreamRouter) Accept(ctx context.Context) (*StreamConn, error)

Accept returns the next newly opened stream, blocking until one arrives, ctx is cancelled, or the conn closes.

func (*StreamRouter) DialUpstream added in v0.2.0

func (r *StreamRouter) DialUpstream(ctx context.Context, p wire.DialUpstreamParams) (*StreamConn, error)

DialUpstream dials an upstream through the conn and returns a StreamConn routed by this router: inbound bytes arrive via OnStreamDeliver and it is torn down by stream_ended or Close. Unlike an accepted stream it is not queued for Accept.

func (*StreamRouter) OnStreamDeliver added in v0.2.0

func (r *StreamRouter) OnStreamDeliver(p wire.StreamWriteParams) ([]wire.StreamWrite, error)

OnStreamDeliver hands inbound bytes to the stream's Read buffer, buffering them when the stream has not registered yet (a dialed upstream that wrote first).

func (*StreamRouter) OnStreamEnded added in v0.2.0

func (r *StreamRouter) OnStreamEnded(p wire.StreamEndedParams)

OnStreamEnded marks the stream drained and drops it from the registry.

func (*StreamRouter) OnStreamOpen added in v0.2.0

func (r *StreamRouter) OnStreamOpen(p wire.StreamOpenParams) ([]wire.StreamWrite, error)

OnStreamOpen registers the stream and queues it for Accept.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL