Writing plugins¶
This guide covers how to write a pluginv2 Starlark plugin: how to register hooks, the hook function signature, the mutation API for msg, and the sandbox modules available to your script.
Starlark basics¶
Starlark is a subset of Python designed for configuration and extension. Key differences from Python:
- No
importstatement — use the predeclared modules (action,config,crypto,state,store,proxy). - No classes; only functions and primitive types (string, bytes, int, float, bool, list, tuple, dict,
None). print()outputs to the proxy log as a structured entry withpluginandmessagefields.- No file I/O, network access, or system calls.
- Module-level values are frozen after script load. Use
statefor mutable state across hook calls.
Anatomy of a plugin¶
A plugin is a .star file. Its top-level body runs once at proxy boot to register hooks; the registered functions then fire on each matching wire event.
def stamp_auth(msg, ctx):
msg["headers"].append("X-Auth", config["api_key"])
return None # CONTINUE
register_hook("http", "on_request", stamp_auth, phase="post_pipeline")
The register_hook builtin and the predeclared modules are bound to the script's global scope.
register_hook(protocol, event, fn, phase="pre_pipeline")¶
Subscribe fn to fire on (protocol, event) at the given phase.
| Argument | Type | Description |
|---|---|---|
protocol |
string | One of http, ws, grpc, grpc-web, sse, raw, tls, connection, socks5. |
event |
string | Protocol-specific event name (see Hook reference). |
fn |
callable | Starlark function with signature fn(msg, ctx). |
phase |
string | "pre_pipeline" (default) or "post_pipeline". Omit for lifecycle events. |
Returns None. Errors are raised at script load time:
- Unknown
(protocol, event)pair → load fails withnot in RFC §9.3 hook surface. - Invalid
phasevalue → load fails withmust be "pre_pipeline" or "post_pipeline". - Passing
phase=to a lifecycle event (*.on_close,*.on_end,tls.on_handshake,connection.*,socks5.on_connect) → load fails withthis event is lifecycle/observation-only; do not pass phase=. fnnot callable → load fails.
If a plugin's top-level body raises after some register_hook calls have already been made, none of the staged hooks are committed — the partial registration is discarded and the plugin is treated as failed-to-load.
Hook function signature¶
Every hook receives two positional arguments:
| Argument | Type | Description |
|---|---|---|
msg |
dict-like | The protocol-specific message. Snake-case keys, plus a writable raw key. See Data map reference. |
ctx |
object | Per-call metadata (client_addr, tls, transaction_state, stream_state). |
Return values¶
The dispatcher interprets the return value as follows:
| Return | Effect |
|---|---|
None |
CONTINUE with whatever in-place mutations were applied to msg. |
action.CONTINUE |
Same as None. |
action.DROP |
Drop the connection / envelope (only valid where the surface allows DROP). |
action.RESPOND(...) |
Synthesize an HTTP response and short-circuit the upstream call. |
action.RESPOND_GRPC(...) |
Synthesize a gRPC end and short-circuit the upstream call. |
| Anything else | Logged as a warning; treated as CONTINUE. |
In-place mutations to msg are committed regardless of the return value (per RFC §9.3 D2 — "no silent drop"). Even if a hook raises, mutations applied before the error are kept and the hook is treated as CONTINUE.
A return of action.DROP or action.RESPOND(...) from an event whose surface forbids it is logged and demoted to CONTINUE. See Hook reference for the per-event action permissions.
Mutating msg¶
The msg argument behaves like a dict over snake-case fields plus a magic "raw" key.
Scalar fields¶
Reassign with item assignment:
Unknown keys (typos like msg["headerz"]) and read-only fields (anomalies, denormalized service/method on grpc.on_data) raise an error at hook return — never silently dropped.
Headers and trailers¶
Header- and trailer-typed fields (HTTP headers/trailers, gRPC metadata, gRPC end trailers) are exposed as a headers value: an order-preserved, case-preserved sequence of (name, value) 2-tuples. Iteration and indexing yield 2-tuples; mutation goes through four methods:
| Method | Behavior |
|---|---|
headers.append(name, value) |
Append a new entry. Wire case is preserved verbatim. |
headers.replace_at(i, name, value) |
Replace the entry at index i. Out-of-range raises. |
headers.delete_first(name) |
Remove the first ASCII-case-insensitive match. Returns True/False. |
headers.get_first(name) |
Read the first ASCII-case-insensitive match. Returns the value or None. |
Plus name in headers for ASCII-case-insensitive membership.
msg["headers"].append("X-Auth", config["api_key"])
msg["headers"].delete_first("Cookie")
auth = msg["headers"].get_first("authorization")
sort, dedup, extend, clear, remove, pop, insert are deliberately not exposed — silently normalizing header order or duplicates would violate the wire-fidelity contract. To bulk-replace, assign a new sequence:
msg["raw"]: byte injection¶
msg["raw"] reads as the original wire-observed bytes (bytes). Assigning to it injects raw wire bytes for the outgoing envelope:
This is the lever for HTTP request smuggling, malformed-frame injection, and other tests where the structured msg view cannot represent what you want on the wire. Per RFC §9.3 D4, when both msg["raw"] and message fields are mutated, raw wins — the raw bytes ship verbatim and the structured mutations are recorded only in the variant snapshot. The raw write is capped at 16 MiB.
msg["raw"] only accepts bytes; assigning a string or any other type raises.
The ctx argument¶
ctx exposes per-invocation metadata and the two scoped state dicts.
| Attribute | Type | Description |
|---|---|---|
ctx.client_addr |
string or None |
Client IP (no port). None for events with no client connection. |
ctx.tls |
dict (frozen) or None |
TLS snapshot: sni, alpn, version_name, cipher_name, peer_cert_subject, client_fingerprint. None when no TLS layer is in the stack. |
ctx.transaction_state |
scoped dict | Per-request/response (HTTP) or per-channel (streaming) KV. |
ctx.stream_state |
scoped dict | Per-stream KV ((ConnID, StreamID)). |
Scoped state API¶
Both ctx.transaction_state and ctx.stream_state expose:
| Method | Description |
|---|---|
.get(key) |
Returns the value or None. |
.set(key, v) |
Sets the key. Only primitive values: string, bytes, int, float, bool. |
.delete(key) |
Removes the key. No-op if absent. |
.keys() |
Returns a list of current keys. |
.clear() |
Empties this scope. |
Limits per scope: 10,000 keys, 1 MiB per string/bytes value.
The two scopes have different lifetimes and are managed by the producing Layer:
- transaction_state — for HTTP, two hooks on the same envelope (a
pre_pipelineand apost_pipelineinvocation) share the dict. For streaming protocols (WS, SSE, gRPC, raw), the dict spans the channel's lifetime so anon_starthook can stash a value anon_datahook reads many frames later. - stream_state — keyed on
(ConnID, StreamID)regardless of protocol. Useful for grouping events that belong to the same wire stream.
The Layer releases each scope at terminal-state transitions (channel close, RST_STREAM, peer GOAWAY, etc.). After release, captured references read as empty and writes are silently dropped — a stale capture cannot resurrect a freed scope.
Sandbox modules¶
These modules are predeclared in every script.
action¶
Action sentinels and response builders.
| Member | Description |
|---|---|
action.CONTINUE |
Sentinel return value for "continue with mutations applied". |
action.DROP |
Sentinel return value for "drop this envelope" (where surface allows). |
action.RESPOND(status, headers, body) |
Builds an HTTP synthesized response. See below. |
action.RESPOND_GRPC(status, message, trailers) |
Builds a gRPC synthesized end. See below. |
action.RESPOND(status_code, headers=[], body=b"")¶
Returns a respond value. When the dispatcher sees this, it short-circuits the upstream call and synthesizes the response envelope.
status_code: int in[100, 999].headers: sequence of(name, value)2-tuples. May be omitted.body:bytesorstring. May be omitted (empty body).
Valid for http.on_request, http.on_response, ws.on_upgrade, grpc.on_start, grpc-web.on_start. Other events log a warning and treat the return as CONTINUE.
action.RESPOND_GRPC(status, message="", trailers=[])¶
Returns a respond value carrying a gRPC end status. Use for grpc.on_start / grpc-web.on_start.
status: uint32 grpc-status code (e.g.7PERMISSION_DENIED,16UNAUTHENTICATED).message: optionalgrpc-messagetext.trailers: optional sequence of(name, value)2-tuples.
config¶
Frozen dict containing the plugin's vars (see Configuration). Read-only.
state¶
Per-plugin volatile in-memory KV. Survives plugin reloads, lost on process restart.
| Member | Description |
|---|---|
state.get(key) |
Returns the value or None. |
state.set(key, v) |
Sets the key. Primitives only: string, bytes, int, float, bool. |
state.delete(key) |
Removes the key. |
state.keys() |
Returns a list of current keys. |
state.clear() |
Removes all keys. |
Limits: 10,000 keys, 1 MiB per string/bytes value.
store¶
Per-plugin persistent KV backed by SQLite. Same API as state but values survive process restarts. Only string and bytes values are accepted. Available only when the proxy is started with a database connection — otherwise store is not predeclared.
crypto¶
Wraps Go's standard library hash, HMAC, AES, and encoding helpers. All hash/HMAC functions accept bytes and return bytes.
| Family | Functions |
|---|---|
| Hash | crypto.md5, crypto.sha1, crypto.sha256, crypto.sha512 |
| HMAC | crypto.hmac_md5, crypto.hmac_sha1, crypto.hmac_sha256, crypto.hmac_sha512 |
| AES | crypto.aes_encrypt_cbc, crypto.aes_decrypt_cbc, crypto.aes_encrypt_gcm, crypto.aes_decrypt_gcm |
| Encoding | crypto.hex_encode, crypto.hex_decode, crypto.base64_encode, crypto.base64_decode, crypto.base64url_encode, crypto.base64url_decode |
proxy¶
Runtime control.
| Member | Description |
|---|---|
proxy.shutdown(reason) |
Triggers proxy shutdown with the given non-empty reason string. |
Resend, Macro, and fuzz semantics¶
Per RFC §9.3 Decision item 1: when a request is replayed via resend_* MCP tools, fanned out via Macro, or generated via fuzz_*, only phase="post_pipeline" hooks fire. pre_pipeline hooks run only on fresh wire receive.
The practical implication: a signing or auth-stamping plugin that needs to run on every variant — normal traffic, resend, Macro, fuzz — should register a single phase="post_pipeline" hook. No special-case routing is required.
def sign(msg, ctx):
body = msg["body"]
sig = crypto.hex_encode(crypto.hmac_sha256(config["secret"], body))
msg["headers"].append("X-Signature", sig)
register_hook("http", "on_request", sign, phase="post_pipeline")
Errors and debugging¶
on_error: "skip" vs "abort"¶
| Value | Behavior on hook error |
|---|---|
"skip" (default) |
Log a warning, continue to the next hook in the chain. |
"abort" |
Stop the chain and surface the error to the caller. |
Use "abort" during development to surface errors immediately. Switch to "skip" in production.
print() for logging¶
print() in Starlark emits a structured log entry tagged with plugin and hook:
plugin_introspect¶
The plugin_introspect MCP tool surfaces every loaded plugin and its (protocol, event, phase) registrations. Use it to confirm that your register_hook() calls landed as expected. See plugin_introspect.
Related pages¶
- Hook reference — The 17-entry surface table.
- Data map reference — Per-protocol
msgdict shape. - Examples — Ready-to-use plugin samples.