proxy

package
v0.1.2 Latest Latest
Warning

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

Go to latest
Published: Aug 29, 2026 License: Apache-2.0 Imports: 32 Imported by: 0

Documentation

Overview

Package proxy is the behalf MCP stdio interposer — the canonical v1 capture surface and reference implementation (D4, Q44).

It sits between an MCP client and a real MCP server over stdio and forwards newline-delimited JSON-RPC in both directions VERBATIM, with one exception: on client->server `tools/call` requests it splices the two legal keys into `params._meta` (the chain under `sh.behalf/chain`, W3C trace context under `baggage` — Q15, Q50, D4). Nothing else is reordered, rewritten or re-serialized; a JSON diff of any forwarded line against the line that arrived differs only inside `params._meta`, and only on tools/call.

MCP revision 2026-07-28 is stateless over stdio, so there is no initialize session to track: each line stands alone and responses are matched to requests by JSON-RPC id.

What gets recorded

Q2's closed rule: every `tools/call` through the proxy is a receipt, reads included. Server->client requests, notifications, unmatched ids and all other traffic cross byte-verbatim and produce no receipts.

Per call, in this order (Q4, Q48):

  1. allocate the per-emitter monotonic counter;
  2. write the raw params bytes into the customer-held CAS;
  3. durably spool the INTENT (fsync) — before anything is forwarded;
  4. forward the request;
  5. on the matching response, build the completion receipt, sign it with the emitter key (DSSE/PAE) and durably spool it;
  6. forward the response.

The proxy never appends to the log: one appender per log (Q57). A drain moves the spool into the log at-least-once, safe because ingest dedups on receipt_id (Q46). A crash between steps 3 and 5 leaves an intent with no completion, and the next Open flushes it as an `orphan_intent` receipt carrying the spooled intent digest (Q4, Q5).

Failure posture

A capture failure — spool, CAS or signing — aborts the proxy rather than forwarding an unrecorded call. behalf's own premise is that a silent gap is indistinguishable from tampering (Q45), so a recorder that cannot record must not pretend to. This is stricter than Q47's observe-mode default, which concerns log backpressure, not a broken capture surface.

Verification at capture

The proxy verifies the chain it forwards (Q18): the D5 root predicate at depth 0, the AAT signature chain and its invariants above it, and the attenuation comparison over the raw RFC 9396 grants — all offline, all in customer territory, all in internal/aat. The per-hop `{status, method, evidence_ref}` in the receipt is that result; the receipt-level rollup is the weakest hop (Q12). A carried hop's own claim about its verification status is discarded on the way in.

The chain and the login material are both fixed for the life of the process, so verification runs once at startup rather than per receipt.

Not in Week 3

This is observe mode only: the proxy records what it checked, and does not enforce leaf scope before forwarding (Q47's opt-in enforcement mode). A chain that verifies as `broken` is recorded as broken and forwarded, per Q45 — append and flag, never gate.

Index

Constants

View Source
const (
	// MetaKeyChain carries the AAT chain material (D4, Q15).
	MetaKeyChain = "sh.behalf/chain"
	// MetaKeyBaggage carries W3C baggage (D4, Q50).
	MetaKeyBaggage = "baggage"
	// BaggageRunKey is the baggage member the proxy contributes.
	BaggageRunKey = "behalf-run-id"
)

The one legal injection. MCP revision 2026-07-28 reserves `params._meta` for out-of-band metadata under vendor-prefixed keys whose second label is not "mcp", which is exactly what D4/Q15 rely on: the delegation chain travels beside the request under `sh.behalf/chain`, and W3C trace context travels under `baggage`. Verification comes from the chain's own signatures, so out-of-band carriage costs nothing (Q15).

View Source
const (
	// EnvRunID is the caller/SDK-supplied key — the top rung.
	EnvRunID = "BEHALF_RUN_ID"
	// EnvTraceparent is the W3C traceparent the caller exported; its
	// trace-id field is the root trace_id (Q7, Q50).
	EnvTraceparent = "TRACEPARENT"
)

run_id is populated by normative precedence, and every receipt records which rung fired so grouping is honest about its own provenance (Q7, receipt-schema-v1.md §6 and §9 item 7). The proxy can reach three of the four rungs; `hook-session` belongs to the Claude Code hook surface (D4).

View Source
const (
	ProvenanceCaller       = "caller"
	ProvenanceTraceparent  = "traceparent"
	ProvenanceProxySession = "proxy-session"
)

Provenance values, matching the schema's run_id_provenance enum.

View Source
const CarriageRouteMeta = "mcp-_meta:" + MetaKeyChain

CarriageRouteMeta records that a hop arrived beside the request in params._meta rather than in band (Q15, D4).

View Source
const DefaultPolicyJSON = `` /* 374-byte string literal not displayed */

DefaultPolicyJSON is the built-in policy used when no --policy file is given. It is a real config, digested like any other, so a receipt written without a policy file still says exactly what classified it.

View Source
const DefaultSpoolDirName = "proxy-spool"

DefaultSpoolDirName is the capture spool under the state directory.

View Source
const KindOrphanIntent = "orphan_intent"

KindOrphanIntent is the record kind recovery flushes (Q4, Q5).

View Source
const KindToolCall = "tool_call"

KindToolCall is the record kind for a tools/call crossing (Q6).

View Source
const MethodToolsCall = "tools/call"

MethodToolsCall is the one method the proxy treats as a trust-boundary crossing (Q2's closed rule: every MCP tools/call through the proxy is a receipt). Everything else crosses byte-verbatim.

View Source
const OtelConventionsVersion = "1.29.0"

OtelConventionsVersion is the gen_ai.* semantic-conventions version in force at capture, stamped per record so old receipts can be re-normalised when the still-Development conventions move (Q8, Q49).

View Source
const Surface = "mcp-proxy"

Surface is the emitter.surface value for this capture surface — the canonical v1 surface (Q44, D4).

Variables

This section is empty.

Functions

func FixedClock

func FixedClock(t time.Time, step time.Duration) func() time.Time

FixedClock returns a clock that starts at t and advances by step on every read. A recorder wants a clock that moves — receipts carry captured_at, and a run whose 47 steps share one timestamp reads as a lie about what happened — but moves by a fixed amount, so the same script produces the same timeline every time.

A zero step returns a frozen clock, which is what a test that only cares about ordering wants.

func FixedEntropy

func FixedEntropy(seed string) io.Reader

FixedEntropy returns a reproducible entropy stream seeded by seed — SHA-256 in counter mode, which is deterministic, endless, and has no short cycles the way a small LCG would.

Seed it with something run-scoped. Two runs recorded with the same seed and the same clock mint the same receipt_ids, and the log dedups on receipt_id (Q46): the second run would be swallowed as duplicates of the first. Seeding per run id is what keeps two recordings of the same script distinguishable while keeping each one reproducible.

func RecoverOrphans

func RecoverOrphans(cfg Config) (int, error)

RecoverOrphans mints, signs and spools an `orphan_intent` receipt for every intent left unmatched in the spool at dir. It is what the drain calls; the proxy runs the same path at startup. Returns how many were flushed.

func Run

func Run(cfg Config, stdin io.Reader, stdout, stderr io.Writer) error

Run spawns the server command and interposes on the stdio streams until the server's stdout closes, then returns the server's exit status.

Types

type Chain

type Chain struct {
	Raw  []byte    // compacted JSON, injected verbatim into _meta
	Hops []aat.Hop // parsed hops, verified at capture and embedded whole (Q10, Q18)
}

Chain is loaded chain material.

func LoadChain

func LoadChain(pathname string) (*Chain, error)

LoadChain reads chain material. An empty path returns nil: absent chain means no injection and no authority block.

func ParseChain

func ParseChain(raw []byte) (*Chain, error)

ParseChain compacts and parses chain material bytes.

type Config

type Config struct {
	// StateDir is the resolved behalf state directory (identity.ResolveDir).
	// It holds the emitter key, the monotonic counter and, by default, the
	// spool and the CAS. Required.
	StateDir string
	// SpoolDir defaults to <StateDir>/proxy-spool.
	SpoolDir string
	// CASDir defaults to <StateDir>/blobs — the customer-held payload store.
	CASDir string
	// PolicyPath is the tool-policy config; empty uses DefaultPolicyJSON.
	PolicyPath string
	// ChainPath is the chain material; empty means no injection and
	// `unattributed` receipts.
	ChainPath string
	// Command is the real MCP server command and its arguments. Required.
	Command []string
	// Env is the server's environment; nil inherits the proxy's.
	Env []string
	// Getenv resolves the run_id precedence rungs; nil uses os.Getenv.
	Getenv func(string) string
	// Now overrides the clock; nil uses time.Now. Set by tests and by
	// cmd/behalf-record's deterministic recording mode (see
	// deterministic.go); production leaves it nil.
	Now func() time.Time
	// Entropy overrides the ULID entropy source that mints receipt_id and
	// intent_id; nil uses crypto/rand. Set by deterministic recordings, and
	// only ever alongside Now — the two together are what make a recording
	// byte-reproducible (see deterministic.go).
	Entropy io.Reader
}

Config configures Run.

type Policy

type Policy struct {
	Version string `json:"version"`
	Default string `json:"default"`
	Rules   []Rule `json:"rules"`
	// contains filtered or unexported fields
}

Policy is a loaded tool-policy config plus the digest of the exact bytes it was loaded from.

func LoadPolicy

func LoadPolicy(pathname string) (*Policy, error)

LoadPolicy reads a tool-policy config. An empty path loads DefaultPolicyJSON.

func (*Policy) Classify

func (p *Policy) Classify(tool string) (class, targetArg string)

Classify returns the risk class for a tool name and the argument name (if any) that supplies operation.target.

func (*Policy) Digest

func (p *Policy) Digest() string

Digest is the sha256 of the config bytes, recorded on every receipt the policy classifies (Q6).

func (*Policy) OutcomeFields

func (p *Policy) OutcomeFields(tool string) []string

OutcomeFields returns the result fields the matching rule records in operation.outcome, or nil when the tool has no rule or the rule names none.

func (*Policy) Source

func (p *Policy) Source() string

Source names where the policy came from, for diagnostics only.

type Rule

type Rule struct {
	Pattern   string `json:"pattern"`
	Class     string `json:"class"`
	TargetArg string `json:"target_arg,omitempty"`
	// OutcomeFields names the top-level scalars of the tool's structured
	// result that are recorded in operation.outcome.
	OutcomeFields []string `json:"outcome_fields,omitempty"`
}

Rule maps a tool-name glob to a risk class.

Jump to

Keyboard shortcuts

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