Skip to main content

View as markdown

# Plugins

Most of the protocols Claw Patrol gates — HTTPS, Postgres, ClickHouse, SSH, Kubernetes — ship as built-in plugins compiled into the gateway binary. When you need to gate something the binary doesn’t know about, you can ship an external plugin: a separate Go program the gateway spawns as a subprocess and talks to over gRPC, modeled on Terraform’s provider design.

External plugins extend exactly the same registry the built-ins use. They can declare:

# Loading a plugin

Add a plugin block to the gateway HCL and reference its types the same way you reference built-ins:

plugin "example" {
  source = "./pluginsdk/example/example"
}

credential "example_magic_token" "demo_token" {}

endpoint "example_smtp" "demo-mail" {
  hosts      = ["mail.invalid:25"]
  credential = example_magic_token.demo_token
}

The name label ("example") is informational — it’s the local identifier you’d use to refer to this plugin’s source in tooling. The names that actually matter are the type names and facet names the plugin declares in its manifest. Both are flat strings living in one global registry per kind (one for endpoint types, one for credential types, one for tunnel types, one for facets, each shared with the built-ins). The gateway does not auto-namespace anything.

Plugin authors prefix their own names by convention — the way Terraform providers do (aws_iam_role, kubernetes_deployment): the SMTP endpoint in the example plugin is example_smtp, its credential is example_magic_token, its custom facet is also example_smtp (endpoint types and facets live in different registries, so reusing one name for the matched pair is fine and often clearer). A plugin that ships a name colliding within a registry — with a built-in (e.g. https endpoint type, http facet) or another plugin — fails at validate time with a clear diagnostic.

# Installing a plugin from GitHub

source can be a local path (above) or a GitHub repository. With a repository, clawpatrol resolves a semver constraint against the repo's release tags, downloads the build for this host's OS/arch, verifies it, caches it, and pins the resolved version in the lockfile — the same model Terraform uses for providers:

plugin "aws" {
  source  = "github.com/denoland/clawpatrol-plugin-aws"
  version = "~> 1.2"   # newest 1.x ≥ 1.2 ; omit for the newest stable
}

version is a hashicorp/go-version constraint (>= 1.2.0, ~> 1.2, ~> 1.2.0, >= 1.0, < 2.0, an exact 1.2.3). ~> 1.2 allows 1.x ≥ 1.2; ~> 1.2.0 allows 1.2.x. Pre-release tags are excluded unless you pin one exactly. version is only valid on a GitHub source.

# install, update, lock

The download is an explicit, reviewable step — the running gateway only ever loads the locked version, and never upgrades on its own:

clawpatrol plugins install <config.hcl> [name...]   # download + pin
clawpatrol plugins update  <config.hcl> [name...]   # re-pin to the newest match
clawpatrol plugins lock    <config.hcl> [name...]   # record all platforms' hashes
clawpatrol plugins info    <config.hcl> [name...]   # required privileges, no download

plugins info reads each GitHub plugin's signed static manifest for the newest release satisfying its constraint and prints the metadata and the privileges it requires — without downloading the binary — so you can review what an install or upgrade would grant before it happens.

The gateway checks GitHub for newer matching releases in the background and surfaces "update available" on the dashboard's Plugins page, but never downloads or upgrades automatically — you run update.

Commit clawpatrol.lock.hcl. On a fresh host the gateway downloads exactly the locked version (verifying it) on first load; if the locked version no longer satisfies the constraint, or the source changed, it fails closed and tells you to run install / update.

# How a plugin must package its releases

So clawpatrol can find and verify the right binary, a plugin's GitHub release must contain, per platform, an archive named with the Go GOOS/GOARCH tokens, plus a checksums file:

<repo>_<version>_<os>_<arch>.tar.gz   # one per platform, holds the binary
<repo>_<version>_SHA256SUMS           # sha256 of each archive (+ manifest)
<repo>_<version>_manifest.json        # static manifest (optional)

Only the trailing _<os>_<arch>.tar.gz is load-bearing — clawpatrol selects the archive by that suffix and reads its checksum from SHA256SUMS — so the prefix is convention, not a hard requirement.

The optional static manifest is the plugin's own manifest emitted by its --print-manifest mode (the SDK's pluginsdk.Run handles the flag). Publishing it as a release asset — listed in SHA256SUMS and covered by the attestation — lets clawpatrol plugins info show a plugin's metadata and required privileges before downloading the binary.

Add a build-provenance attestation with one workflow step so clawpatrol can verify the binary (and the manifest) was built by your repo (see the trust model below):

# .github/workflows/release.yml (excerpt)
permissions:
  contents: write
  id-token: write       # for keyless signing
  attestations: write
steps:
  - run: |
      VER="${GITHUB_REF_NAME#v}"; REPO="${GITHUB_REPOSITORY##*/}"
      mkdir -p dist
      for pl in linux_amd64 linux_arm64 darwin_amd64 darwin_arm64; do
        GOOS="${pl%_*}" GOARCH="${pl#*_}" CGO_ENABLED=0 \
          go build -trimpath -ldflags "-s -w" -o "$REPO" .
        tar -czf "dist/${REPO}_${VER}_${pl}.tar.gz" "$REPO"; rm "$REPO"
      done
      go build -o "$REPO" . && ./"$REPO" --print-manifest \
        > "dist/${REPO}_${VER}_manifest.json" && rm "$REPO"
      ( cd dist && shasum -a 256 *.tar.gz *_manifest.json \
          > "${REPO}_${VER}_SHA256SUMS" )
  - uses: actions/attest-build-provenance@v2
    with:
      subject-path: |
        dist/*.tar.gz
        dist/*_manifest.json
  - uses: softprops/action-gh-release@v2
    with: { files: dist/* }

# Verification and trust

Three layers gate a downloaded binary, from least to most powerful:

  1. Checksum. The archive's sha256 must match the release's SHA256SUMS.

  2. Trust on first use. The extracted binary's hash is recorded in clawpatrol.lock.hcl; every later load re-hashes the cached binary and fails closed on any mismatch — and a version bump re-runs the permission-escalation check (a new release that newly wants network is blocked until you re-approve it).

  3. Build provenance. If the release carries a GitHub build-provenance attestation, clawpatrol verifies (via Sigstore) that the binary was built by that repo's GitHub Actions workflow — the github.com/owner/repo you already named is the trust anchor, so there is no key to manage. This closes the first-download gap that checksums alone leave open. The attestation also names the source commit the binary was built from; clawpatrol records it in the lockfile (commit = "...") — an immutable reference, since tags are mutable — and on a later re-download of the pinned version rejects an attestation that names a different commit (a re-pointed tag).

    How a missing attestation is treated is set per plugin by provenance:

    plugin "aws" {
      source     = "github.com/denoland/clawpatrol-plugin-aws"
      version    = "~> 1.2"
      provenance = "require"   # "warn" (default) | "require" | "off"
    }
    • "warn" (default) — verify an attestation when present, else install checksum-only with a warning, so plugins that have not adopted attestations still install.
    • "require" — refuse a release that carries no attestation; use it for plugins you hold to the higher bar.
    • "off" — skip the attestation check (checksum + lockfile pinning still apply).

    A present-but-invalid attestation always fails closed (every mode but "off").

    Provenance is also tracked trust-on-first-use, like the network grant: the lockfile records whether the pinned version was attested, and a later binary that loses provenance — attested before, not now — is blocked (a re-pointed tag, or an upgrade that drops its attestation, looks exactly like a supply-chain attack) until you accept it with clawpatrol plugins approve <name>. A plugin that was never attested keeps installing with a warning; only a downgrade is blocked.

# Sandbox and capability grants

Plugins are untrusted. A plugin runs in the gateway process tree and the gateway holds secrets (the state DB with the CA key and credential material, WireGuard / Tailscale keys, CLAWPATROL_SECRET_* environment variables). To contain a malicious or compromised plugin, every plugin subprocess runs inside an OS sandbox by default and with a scrubbed environment — it inherits none of the gateway's environment, only PATH, HOME, TMPDIR (pointing at a private scratch dir) and the plugin socket path.

Permissions come from three places, by risk:

plugin "ssh_tools" {
  source = "./plugins/ssh_tools"

  sandbox    = "enforce"     # "enforce" (default) | "off"
  read_paths = ["~/.ssh"]    # extra recursive read-only grants
  # network is NOT written here — the plugin declares it (see below).
  # network = "outbound"     # optional operator override / veto
}

# Network: declared by the plugin, recorded in a lockfile

A plugin states its network need in its manifest:

pluginsdk.Run(&pluginsdk.Plugin{
    Name:         "ssh_tools",
    Capabilities: pluginsdk.Capabilities{Network: pluginsdk.NetworkOutbound},
    // …
})

NetworkNone (the default) cuts the plugin off from the network — its only channel is the gateway socket, and upstream connections go through the gateway's brokered dial. This is enough for almost every plugin: endpoint plugins reach upstreams with the brokered dial, and tunnel plugins open their own transport with the tunnel transport dial, so both run network = "none" with no socket of their own. NetworkOutbound is for the few plugins that genuinely dial out themselves and can't use the broker — a plugin that execs helper tools (ssh, kubectl) which open their own connections, or a credential plugin doing its own token exchange.

On first load the gateway records the plugin's declared network in clawpatrol.lock.hcl next to the config (commit it to VCS):

plugin "ssh_tools" {
  network = "outbound"
  hashes = [
    "sha256:…", # linux-amd64
    "sha256:…", # darwin-arm64
  ]
}

hashes is the set of approved binary hashes — one per platform build — so a single committed lockfile covers a team's different OS/arch hosts (and a future distribution system can record every platform's hash for a release at once). A binary is approved iff its hash is in the set; they all share the same approved permissions.

This is trust-on-first-use. A binary whose hash isn't in the set is re-checked: if it requests no more than the recorded permissions (a new platform build, or a same-permission version) its hash is added; if it escalates — say it now asks for outbound when the lockfile recorded none — config load fails closed with a loud diagnostic. That is exactly what a compromised plugin update trying to open an exfiltration path looks like. After an intentional upgrade, re-approve it:

clawpatrol plugins approve <config.hcl> ssh_tools

Or approve it from the dashboard: any blocked plugin — an escalated network/egress grant, a lost provenance attestation, or a declared privileged request — shows on the Plugins page with the reason and an Approve button that records the grant and reloads, no CLI needed. This is the usual flow after installing or upgrading a GitHub plugin whose new release wants more than the lockfile recorded.

An operator can still set network on the plugin block to override the plugin's request (force or veto); the override wins and is what gets recorded.

# Privileged: declared by the plugin, approved explicitly

Some plugins genuinely cannot run sandboxed — they need to exec arbitrary helper tools (ssh, kubectl, aws, …) and read the user's tool configs (~/.ssh, ~/.aws, ~/.kube). Enumerating which binaries and paths such a plugin touches is hopeless (a plugin that can run a shell can run anything), so there is no fine-grained "exec" grant: a plugin that needs this declares a single coarse privileged capability, which runs it with the sandbox off — the same full host access as the operator's sandbox = "off".

pluginsdk.Run(&pluginsdk.Plugin{
    Name:         "ssh_tools",
    Capabilities: pluginsdk.Capabilities{Privileged: true},
    // …
})

Because privileged hands the plugin full host access — it can read every file the gateway user can, including clawpatrol's own secret store, and run any command — it is not trust-on-first-use like network and egress. The gateway holds a privileged plugin closed until the operator approves it explicitly:

clawpatrol plugins approve <config.hcl> ssh_tools

Approval records privileged = true in clawpatrol.lock.hcl, gated on the binary hash like every other grant, so a later version re-pends approval. The dashboard's Plugins page shows the request on the blocked card (a red privileged badge) and offers the same one-click approve.

plugin "ssh_tools" {
  network    = "outbound"
  privileged = true
  hashes     = ["sha256:…"]
}

privileged is the plugin asking for what sandbox = "off" grants; the operator's sandbox = "off" on the block is the operator forcing the same thing directly (and wins outright, no approval needed). Prefer a narrower capability — Egress, a built-in tunnel — whenever one fits; reach for privileged only when the plugin must shell out.

# Filesystem and full-trust grants (operator-only)

Backends, by platform:

PlatformBackendIsolation
Linuxnamespacesuser + mount + pid (+ network when network="none") namespaces, a deny-by-default mount tree, dropped capabilities, no_new_privs
Linux (userns blocked)Landlockfilesystem deny-by-default; TCP bind/connect denied on kernels with Landlock ABI ≥ 4. Degraded — loads with a warning
macOSseatbeltsandbox-exec deny-default profile
othernone; the plugin requires sandbox = "off"

On Linux hosts where unprivileged user namespaces are disabled (e.g. Ubuntu 24.04 with kernel.apparmor_restrict_unprivileged_userns=1), the gateway automatically falls back to Landlock and logs a warning describing what the fallback does not cover. If neither backend works and sandbox is not "off", the plugin block fails to load with a diagnostic naming the cause and the opt-out.

Changing a plugin's sandbox or network grants takes effect on the next gateway restart, not on config hot-reload.

# Brokered upstream dial

An endpoint plugin that needs to reach an upstream service does not open the connection itself — it asks the gateway to:

c, err := conn.DialUpstream(ctx, "tcp", "api.example.com:443",
    &pluginsdk.DialUpstreamOptions{TLS: true})

The gateway opens the connection on the plugin's behalf, routes it through the endpoint's bound tunnel when one is configured, optionally terminates upstream TLS (real certificate verification — TLS: true), audits the attempt, and hands back a net.Conn. This is what lets endpoint plugins run with network = "none": they receive credential secrets but cannot exfiltrate them, because they have no socket of their own.

The gateway only dials targets sanctioned for that endpoint instance:

  1. the exact host:port the agent originally dialed,
  2. an entry of the endpoint's hosts list,
  3. an entry of the plugin's manifest-declared egress set, or
  4. an entry of the endpoint's dial allow-list:
endpoint "example_https" "demo-site" {
  hosts    = ["demo.invalid"]
  upstream = "http://10.0.0.5:8000"
  dial     = ["10.0.0.5:8000", "*.internal.svc:443"]
}

Any other target is refused and audited (a dial / deny event on the dashboard). Plugin-supplied config is never consulted for dial authorization.

# Manifest-declared egress

A plugin that always needs to reach the same upstreams — an AWS plugin talking to *.amazonaws.com, say — declares them in its manifest rather than making every operator hand-write a dial list:

Capabilities: pluginsdk.Capabilities{
    Egress: []string{"*.amazonaws.com:443"},
},

Each entry is host:port or *.suffix.tld:port (the same shape as dial). The gateway records the approved set in clawpatrol.lock.hcl on first load (trust-on-first-use, alongside the network grant) and merges it into every one of the plugin's endpoints' dial allow-list. An upgrade that broadens egress — a new version that wants a destination none of the approved entries cover — fails closed until the operator re-approves it (clawpatrol plugins approve), exactly like a network-grant escalation; a narrower or equal set loads unchanged. The declared set is verified against the signed static manifest, so clawpatrol plugins info shows a plugin's egress before any binary is downloaded. The operator's dial list still works and composes with the manifest set — use it for site-specific upstreams the plugin author can't know.

DialUpstream requires a gateway that supports the brokered-dial protocol; against an older gateway it returns pluginsdk.ErrDialUpstreamUnsupported immediately (rather than hanging), and the plugin must fall back to its own net.Dial with an operator-granted network = "outbound".

Brokered dials and the agent connection are multiplexed over one gRPC stream, so a plugin must keep reading every dial it opens concurrently with the agent connection. A plugin that opens a dial and then stops reading it can stall its own connection's other traffic (other dials, audit events, the agent response). This only affects the one connection the plugin is handling, but a misbehaving plugin can wedge itself — drain your dial conns.

# Tunnel transport dial

A tunnel plugin is the transport — it teaches the gateway how to reach an upstream that isn't directly routable (through a SOCKS proxy, a VPN, an SSH bastion). Like an endpoint plugin, it does not open that transport socket itself; from inside its Dial (or Open) it asks the gateway:

c, err := req.DialUpstream(ctx, "tcp", cfg.Proxy)

The gateway opens the transport on the plugin's behalf, routes it (directly, or — when the tunnel is chained — through the parent tunnel), and hands back a net.Conn. The plugin opens no socket, so a tunnel plugin also runs with network = "none".

req.DialUpstream accepts "udp" as well as "tcp". For UDP the returned conn carries length-prefixed datagrams; wrap it with pluginsdk.PacketConnOverStream for a net.PacketConn, or use pluginsdk.WriteDatagram / ReadDatagram directly. This is what lets a UDP-based tunnel (WireGuard) ride over a stream-based one (a SOCKS proxy): the datagrams are framed over the conduit end to end.

The canonical example is example_socks (pluginsdk/example/socks.go): a SOCKS5 tunnel that opens both its TCP control connection (SOCKS CONNECT) and its UDP relay (SOCKS UDP ASSOCIATE) with req.DialUpstream, and runs network = "none".

Chaining (via). The gateway routes a tunnel's transport through the parent tunnel named by via = <tunnel> — this is how a chain composes without any plugin knowing about the others (a WireGuard tunnel's handshake routed through a SOCKS tunnel, say). via works on plugin tunnel blocks just like built-in ones, in either role (parent or child); see Chaining tunnels.

# Writing a plugin

Plugins are ordinary Go programs. The author SDK lives at github.com/denoland/clawpatrol/pluginsdk; the canonical example is pluginsdk/example/ in the Claw Patrol repo.

package main

import "github.com/denoland/clawpatrol/pluginsdk"

func main() {
    pluginsdk.Run(&pluginsdk.Plugin{
        Name:    "example",
        Version: "0.1",
        Credentials: []pluginsdk.CredentialDef{magicTokenDef()},
        Endpoints:   []pluginsdk.EndpointDef{demoSMTPDef()},
        Facets: []pluginsdk.FacetDef{{
            Name: "example_smtp",
            Fields: []pluginsdk.FacetField{
                {Name: "verb", Kind: pluginsdk.FacetString, Label: "Verb"},
                {Name: "mail_from", Kind: pluginsdk.FacetString, Label: "From", Optional: true},
                {Name: "body", Kind: pluginsdk.FacetStream, Label: "Body", Optional: true},
            },
        }},
    })
}

pluginsdk.Run blocks the process while the gateway is connected. Build with go build like any Go binary; deploy by setting source = "<path>" in the gateway HCL.

# External credential metadata and HTTPS injection

External credential plugins are trusted gateway components. The gateway may send them credential secret bytes over the local plugin RPC channel when a request is about to leave through the built-in HTTPS endpoint. Only load plugin binaries you trust, and protect the paths they are loaded from the same way you protect the gateway binary.

A credential can return pluginsdk.CredentialBuildResult from Build to publish dashboard secret slots, env pushdown placeholders, OAuth flow metadata, and HTTPS injection support while keeping its canonical config opaque to the gateway:

pluginsdk.CredentialDef{
    TypeName:       "example_bearer",
    Disambiguators: []string{"placeholder"},
    HTTPInject:     true,
    Build: func(req pluginsdk.BuildRequest) (any, error) {
        return pluginsdk.CredentialBuildResult{
            Canonical: map[string]any{},
            Metadata: pluginsdk.CredentialMetadata{
                SecretSlots: []pluginsdk.SecretSlot{{Label: "Bearer token"}},
                EnvVars: []pluginsdk.EnvVar{{
                    Name:  "EXAMPLE_TOKEN",
                    Value: "PH_example",
                }},
                HTTPInject: true,
            },
        }, nil
    },
    InjectHTTP: func(ctx context.Context, req pluginsdk.HTTPInjectRequest) (*pluginsdk.HTTPInjectResponse, error) {
        return &pluginsdk.HTTPInjectResponse{Headers: []pluginsdk.HeaderMutation{{
            Op:     pluginsdk.HeaderSet,
            Name:   "Authorization",
            Values: []string{"Bearer " + string(req.CredentialSecret)},
        }}}, nil
    },
}

Disambiguators declares which credential/profile attrs are valid for multi-credential dispatch. For HTTP credentials the conventional field is placeholder: the agent sends a placeholder-looking token, and the built-in HTTPS endpoint selects the matching credential before calling InjectHTTP. InjectHTTP is header-only by design — the request body never flows through the plugin — so it stays cheap for the common case. Use HeaderSet for auth headers such as Authorization; HeaderAdd appends and may leave the agent's placeholder value in place. A credential that must rewrite the URL or body uses TransformHTTP instead (below).

# Rewriting the URL or body: TransformHTTP

A credential that signs over the request body (AWS SigV4) or carries its secret in the URL or body (telegram, discord) sets HTTPTransform: true and implements TransformHTTP. The request body is streamed to the plugin as an io.Reader, and the plugin returns the header/method/URL mutations plus the outgoing body — so buffering is the plugin's choice: read a prefix and stream the rest, or read it all to sign.

pluginsdk.CredentialDef{
    TypeName:      "example_sigv4",
    HTTPTransform: true,
    TransformHTTP: func(ctx context.Context, req pluginsdk.HTTPTransformRequest) (*pluginsdk.HTTPTransformResponse, error) {
        body, err := io.ReadAll(req.Body) // SigV4 needs the whole body to hash
        if err != nil {
            return nil, err
        }
        sig := sign(req.Method, req.URL, req.Headers, body, req.CredentialSecret)
        return &pluginsdk.HTTPTransformResponse{
            Headers: []pluginsdk.HeaderMutation{
                {Op: pluginsdk.HeaderSet, Name: "Authorization", Values: []string{sig}},
            },
            Redactions: []string{sig},
            Body:       bytes.NewReader(body), // forward the body unchanged
        }, nil
    },
}

Set Response.Body = req.Body to pass the input straight through (no buffering — right for a URL-only rewrite). The gateway applies the returned mutations before forwarding, so a body-derived signature header is finalized first, then pipes the plugin's body upstream. If you change the body length, set a Content-Length header mutation (or leave it unset to forward with chunked transfer). HTTP trailers (e.g. gRPC's) are preserved by the gateway across the transform — the plugin does not handle them. If the plugin fails, the gateway fails the request closed (the body was already streamed away), rather than forwarding a half-transformed request.

At runtime the built-in HTTPS endpoint keeps the privilege split:

  1. The wrapped process sees only metadata and placeholders from EnvVars (for example EXAMPLE_TOKEN=PH_example).
  2. The gateway resolves the matched credential and fetches its gateway-held secret.
  3. The gateway calls the external plugin's InjectHTTP callback with request metadata and that secret.
  4. The plugin returns header mutations and any derived strings that should be redacted from audit samples.

Redactions are part of the security contract. The gateway automatically redacts the raw credential secret it fetched, and audit samples also mask obviously sensitive header names such as Authorization. If your plugin injects any derived secret (for example an exchanged JWT or HMAC signature), return the exact derived string in HTTPInjectResponse.Redactions. Otherwise a derived value placed in a non-sensitive header such as X-Signature may appear verbatim in request audit samples.

OAuth credentials can set CredentialMetadata.OAuth instead of secret slots. OAuth metadata is intentionally Build-time and instance-scoped, so two HCL blocks of the same credential type can select different regions, URLs, scopes, or flows. The gateway owns the OAuth lifecycle and stores or refreshes tokens under the credential instance name; the external credential receives the current access token as CredentialSecret when HTTPS injection runs. Dynamic MCP OAuth providers should set Flow: "dynamic_mcp"; the gateway will use public-client PKCE exchange and refresh behavior selected by that flow, not by a hardcoded provider hostname.

InjectHTTP is one gateway→plugin RPC round trip per proxied request. External credentials that need provider-specific exchange logic (for example exchanging a durable service-account token for a short-lived JWT) should do that inside InjectHTTP, but must cache the derived token in plugin memory and reuse it until expiry — do not mint a fresh token on every request. The gateway bounds each InjectHTTP call with a 30s deadline; a plugin that exceeds it is logged and the request is forwarded without injection. Validate any provider base URLs before sending long-lived secrets: plugin HCL is operator configuration, but the plugin process is still the component that knows which upstream hosts are allowed to receive its secret material.

# Endpoints own the connection

For each accepted agent connection on a plugin endpoint, the gateway hands the plugin a *pluginsdk.Conn — a net.Conn plus the connection’s profile / peer-IP / credential secret context. The plugin owns the byte stream from there on.

func handleSMTP(ctx context.Context, conn *pluginsdk.Conn) error {
    // ... parse the protocol ...
}

For TLSMode: pluginsdk.TLSTerminate, the gateway terminates TLS using its own CA before handing over the Conn — the plugin sees plaintext bytes and just speaks the inner protocol (HTTP, ESMTP, …). For pluginsdk.TLSNone the plugin gets the raw TCP socket.

# Asking the gateway for a verdict

Plugins must not decide allow/deny themselves. They build a structured action and ask the gateway:

verdict, err := conn.Evaluate(ctx, "example_smtp", map[string]any{
    "verb":      "MAIL",
    "mail_from": "alice@example.com",
}, "MAIL FROM:<alice@example.com>")

The gateway:

  1. Walks the matched endpoint’s compiled rule list with the action map bound to the named facet (so a rule like example_smtp.verb == "MAIL" evaluates).
  2. Runs any approve chain (LLM judge, human approver) for rules whose outcome is approve = […]. Protocol plugins must translate denies and timeouts into native failure responses without calling upstream.
  3. Logs the action onto the dashboard event stream with the action map as the facet payload.
  4. Returns verdict.Action ("allow" / "deny" / "hitl_allow" / "hitl_deny") plus reason and matched rule name.

The plugin then translates the verdict into whatever the protocol needs (250 vs 550 for SMTP, 200 vs 403 for HTTP, etc.).

Conn.Emit is for non-policy events only — operational failures, session-open/close milestones, anything where no rule fired. A hand-rolled Action: "allow" via Emit fabricates a verdict no rule produced; use Evaluate instead.

# Stream-typed facet fields

A facet field declared with Kind: pluginsdk.FacetStream is a lazy bytes value. The plugin offers the field as pluginsdk.Stream(io.Reader):

verdict, err := conn.Evaluate(ctx, "example_smtp", map[string]any{
    "verb": "BODY",
    "body": pluginsdk.Stream(bytes.NewReader(messageBody)),
}, "BODY (4096 bytes)")

The gateway pulls bytes only as deeply as needed:

When the plugin sees the cancel it can drop its source reader. Bodies that overflow the cap mark the request Truncated: the stream-typed fields become CEL unknowns and any rule whose condition outcome depends on one is auto-denied (the same unevaluable fail-close that protects the built-in HTTPS body buffer).

# Reporting the outcome: status and a response body

Conn.SetResult is the after-the-fact counterpart of Evaluate: once the response is known, the plugin reports the action's outcome. It mirrors the request side — a result schema of named fields, declared in the manifest as FacetDef.ResultFields (the same FacetField shape, so Title / Description / DetailOnly and Kind: pluginsdk.FacetStream all apply):

Facets: []pluginsdk.FacetDef{{
    Name:   "example_smtp",
    Fields: []pluginsdk.FacetField{ /* request fields */ },
    ResultFields: []pluginsdk.FacetField{
        {Name: "status", Kind: pluginsdk.FacetString, Title: true,
            Description: "Delivery status"},
        {Name: "response_body", Kind: pluginsdk.FacetStream,
            Description: "Server reply"},
    },
}},

The plugin reports the result once, after the response, on the connection:

_ = conn.SetResult(ctx, map[string]any{
    "status":        "250",
    "response_body": pluginsdk.Stream(replyReader),
})

The gateway:

As on the request side, the gateway owns the cap: the plugin offers a reader and has no size opinion. When the gateway has read enough it cancels the stream; the plugin's reader is simply dropped and is never blocked — so reporting a body never stalls or truncates the response the plugin is still sending to the agent.

SetResult is best-effort and finalizes the connection's current action (the one it just evaluated); call it once, after the response is known.

# Optional facet fields

Fields marked Optional: true may be omitted from the action map. The gateway substitutes the kind-zero value (empty string, empty list, empty map, 0) before CEL evaluation, so rule conditions can reference them without has() guards.

The zero-fill covers declared fields only. Selecting anything else is a runtime evaluation error, which fails closed: the rule synthesizes a deny instead of silently no-matching (see "Unevaluable conditions fail closed" in the rules doc). That includes a typo'd field name, a field the manifest never declared, and a nested key off a map-shaped value — e.g. example_smtp.headers.x_priority errors whenever the action's headers map lacks that key. Guard nested lookups the same way as the built-in facets: 'x_priority' in example_smtp.headers && ....

# Reusing a built-in facet

A plugin endpoint that gates HTTPS doesn’t need to redeclare a facet — set Family: "http" on the endpoint and shape the action map with the same keys the built-in http facet exposes (method, path, headers, body):

endpoint := pluginsdk.EndpointDef{
    TypeName: "example_https",
    Family:   "http", // bind to the built-in http facet
    TLSMode:  pluginsdk.TLSTerminate,
    HandleConn: func(ctx context.Context, conn *pluginsdk.Conn) error {
        // ... parse one HTTP request from conn ...
        verdict, _ := conn.Evaluate(ctx, "http", map[string]any{
            "method":  req.Method,
            "path":    req.URL.RequestURI(),
            "headers": req.Header,
            "body":    pluginsdk.Stream(req.Body),
        }, req.Method+" "+req.URL.RequestURI())
        // ... act on verdict ...
    },
}

Rules attached to this endpoint are written exactly the way they would be against any in-process HTTPS endpoint: http.method == "POST", http.body.contains("…"), etc.

The same applies to SQL endpoints. An endpoint that terminates a SQL wire protocol (postgres, mysql, clickhouse, …) sets Family: "sql", parses each statement itself, and sends the coarse fields the built-in sql facet exposes — verb, tables, functions, database, and statement (which may be a pluginsdk.Stream for large queries):

endpoint := pluginsdk.EndpointDef{
    TypeName: "example_sql",
    Family:   "sql", // bind to the built-in sql facet
    TLSMode:  pluginsdk.TLSTerminate,
    HandleConn: func(ctx context.Context, conn *pluginsdk.Conn) error {
        // ... parse one statement from the wire ...
        verdict, _ := conn.Evaluate(ctx, "sql", map[string]any{
            "verb":      "delete",
            "tables":    []string{"tokens"},
            "functions": []string{},
            "database":  db,
            "statement": pluginsdk.Stream(strings.NewReader(stmt)),
        }, stmt)
        // ... act on verdict ...
    },
}

Operators then reuse their existing sql.* rules verbatim — sql.verb == "delete", sets.intersects(sql.tables, ["secrets"]), sql.statement.contains("DROP") — across any SQL plugin. sql is the one shared built-in family exposed for opt-in (SQL has many engines that benefit from a portable coarse guardrail); k8s, ssh, and bespoke protocols use a plugin-declared facet instead.

# Persistent state

A sandboxed plugin has no writable filesystem of its own. When a plugin must remember a few bytes across restarts — an SSH endpoint's host key, a signing keypair, a dynamically registered client id — it uses the gateway-backed state store rather than touching disk:

const hostKeyName = "host_key"

func ensureHostKey(ctx context.Context) ([]byte, error) {
    st := pluginsdk.State()
    if v, found, err := st.Get(ctx, hostKeyName); err != nil {
        return nil, err
    } else if found {
        return v, nil
    }
    key := generateHostKey()
    return key, st.Put(ctx, hostKeyName, key)
}

Reach for pluginsdk.State() from a runtime callback — HandleConn, InjectHTTP, OpenTunnel, or Dial — which is where identity like a host key is actually needed. It is not available during a Build callback on the gateway's first config load: the store lives in the state dir, which is part of the config being loaded, so it is wired only once that load finishes. The gateway namespaces every key by the plugin, so one plugin can never read another's; values are capped at 1 MiB (this is for identity, not bulk data) and survive restarts. The store is reached over the plugin connection — no network grant and no dial entry are involved. If the gateway is too old to provide it, the calls return an error so the plugin can degrade gracefully.

# Validating a plugin config

clawpatrol validate runs the same load path the daemon does, so every plugin referenced from the HCL is spawned and its manifest is checked. Beyond the HCL pipeline the validate command also runs a schema-only pass (Manager.Verify) that catches plugin authoring bugs even when the operator’s HCL doesn’t happen to exercise them:

The success line gains one summary row per loaded plugin so you can see what came up:

ok: gateway.hcl — 7 endpoints across 3 profile(s)
  plugin "example" v0.1: 2 facet(s), 1 credential type(s), 1 tunnel type(s), 3 endpoint type(s)

# See also