Skip to content

Concepts

The model everything else assumes. Worth ten minutes before writing a real config.

An agent on a developer machine or in CI has unrestricted outbound access. It can exfiltrate secrets, fetch and execute arbitrary content, or be steered by a prompt injection into contacting attacker infrastructure. Firewall rules are too coarse to help: agents legitimately need GitHub, npm, PyPI and LLM APIs, and those same hosts are exfiltration channels. The boundary has to understand HTTP, not just IPs.

┌── explicit: CONNECT / SOCKS5 ──┐
agent traffic ──┤ ├──► identity ──► profile
└── Unix HTTP proxy forwarder ───┘ │
▼
┌──────────── policy chain (decides WHETHER) ───────────┐
│ denylist → allowlist → rules → mcp → dlp → judge │
│ each: ALLOW | DENY | PASS(+evidence); first wins │
│ all PASS ⇒ profile.default_action │
└───────────────────────┬───────────────────────────────┘
DENY ─► 403│ ALLOW
▼
┌──────── request_transforms (decide HOW) ─────┐
│ header filter → LLM route → secret injection │
└───────────────────────┬──────────────────────┘
▼
upstream guard (post-resolution IP check)
▼
┌──────── response chain + response_transforms ────────┐
│ LLM translate → size caps → MCP filter │
└───────────────────────┬──────────────────────────────┘
▼
audit record

The HTTP proxy and SOCKS5 frontends converge on one request representation, so policy is written once. The optional DNS server supplies answers but does not provide direct HTTP/TLS ingress; see Capture.

Which policy applies depends on which agent is connecting. Identity is derived from the connection rather than trusted solely because the client claims it. Resolver strength depends on the transport and deployment; client-supplied proxy credentials are the weakest option.

Resolvers are tried in order and are not equal in strength: a kernel-supplied uid cannot be forged, a Proxy-Authorization header trivially can. Anything unresolved gets a synthetic identity and the configured fallback profile, flagged attributed: false in every audit record. Keep that fallback restrictive: marshal does not compare profiles or enforce that it is the least permissive. It can also be redirected to a named profile, or unattributed traffic can be refused outright.

A profile is the unit of policy: an ordered chain of layers, a terminal default_action, and the transforms that apply to what it allows. Exactly one profile is embedded in the base config as the unattributed fallback; every other one is a named file that a resolver or marshal run --profile can target.

Requests pass through an ordered chain of layers. Each returns ALLOW, DENY, or PASS; the first terminal verdict wins, and PASS falls through carrying structured evidence that later layers can reason over.

denylist → allowlist → rules (CEL) → mcp → dlp → judge (LLM) → default_action
trivial trivial cheap cheap moderate expensive

Two consequences worth knowing up front:

  • Ordering is semantic. A denylist at position 1 beats a later LLM approval simply by being first. Layers are ordered cheapest-first, and marshal config check warns when an expensive layer precedes a cheap one.
  • Default-deny lives in default_action, the terminal applied when every layer passed. Setting it to allow requires an explicit acknowledgement in config. This is the single place the product’s core guarantee lives.

Deciding whether is separate from deciding how, and the two directions are separate from each other. Transforms run only after the chain has allowed:

  • request_transforms rewrite an allowed request on its way out — header filtering, LLM model routing, and unconditional credential injection. The client needs no placeholder; keep the real credential outside its environment and accessible filesystem.
  • Responders are the third thing that can happen to an allowed request. A RequestResponder runs last, on the finished request, and may answer it rather than let it reach the upstream — used by in-band OAuth2 capture to complete a protocol exchange marshal has taken over. Every synthesized response carries proxy-agent: bot-marshal. See ADR-0031.
  • response_transforms rewrite what comes back — translating a routed LLM response or applying a response size limit. Response-body redaction, summarization and compaction are declared config shapes but are not implemented; log/audit credential redaction is a separate emission boundary.

Native decision APIs can supply the judge’s bounded verdict without a tool call. They receive the same reduced metadata separately from operator policy; confidence below the configured threshold passes to later policy. Agent decision requests can also be routed, but carry their own state and questions.

A transform declares whether it needs the body buffered, and that declaration is load-bearing rather than advisory: bodies stream by default, and a transform that rewrites content cannot run over a stream. Declaring a body transform is therefore a statement that the responses it applies to are no longer streamable, so marshal config check warns and the profile should scope it away from SSE and WebSocket endpoints. The LLM router is route-aware: it buffers only a matched, non-streaming JSON response, and translates SSE one complete event at a time.

This is why interception does not break streaming: SSE arrives event by event, request bodies forward as they are written rather than being collected first, protocol upgrades become raw bidirectional relays that survive idle periods, and Content-Encoding passes through byte-identical. Buffering never surfaces as an error — only as an agent whose stream goes quiet and then delivers everything at once, which is why these are the tests worth having.

marshal serve refuses to start without a CA. A plain relay cannot enforce per-request policy, and it cannot even guarantee the client reaches the host it claimed.

Shared-IP hosting (a CDN or load balancer serving many sites off one address) routes by the TLS SNI inside the tunnel, which a relay never inspects. A client can CONNECT good.example.com — correctly resolved, guard approved — then present SNI: evil.example.com and have the origin serve that instead, entirely unseen by a proxy that only relays bytes.

Interception defeats this structurally: the proxy re-originates its own TLS to upstream keyed on the CONNECT authority, never on anything the client claims inside the tunnel. The one sanctioned exception is tls.passthrough, for clients that pin certificates and would refuse the proxy’s own cert; a passthrough host still gets the same SNI cross-check on its plain relay. The SOCKS5 front-end gets identical treatment to HTTP CONNECT — same mandatory interception, same passthrough exception, same SNI check.

A CONNECT names a destination and nothing else. When TLS will be intercepted it is treated as a pre-filter: a destination no host-level layer refused proceeds to interception, where rules and dlp make the real call on the actual request.

Otherwise the natural configuration is impossible — a short-circuiting chain means an allowlist with on_match: allow terminates before those layers run, while on_match: pass leaves nothing to permit the tunnel. Nothing reaches the upstream until a request-level verdict allows it. For intercepted TLS, the exception to real-request evaluation is tls.passthrough, where the CONNECT verdict is the sole decision point and default_action governs it strictly — the same trade a certificate-pinned client always makes by opting out of interception.

Between the transforms and the connection, every resolved IP is checked against upstream.deny_cidrs. The hostname is resolved once, each resulting address checked, and the connection made to that checked address — never re-resolved between check and connect, which is what closes DNS rebinding. This is the guard that keeps an allowed hostname from becoming a route to 169.254.169.254.

Every request produces a record carrying the resolved identity, whether it was attributed, which layer decided and why, the full evidence trail, status, and timing — with injected secrets scrubbed. See Observability.