Concepts
The model everything else assumes. Worth ten minutes before writing a real config.
The problem
Section titled “The problem”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.
The path a request takes
Section titled “The path a request takes” ┌── 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 recordThe 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.
Identity selects the profile
Section titled “Identity selects the profile”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.
Profiles hold the policy
Section titled “Profiles hold the policy”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.
The policy chain decides whether
Section titled “The policy chain decides whether”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 expensiveTwo 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 checkwarns when an expensive layer precedes a cheap one. - Default-deny lives in
default_action, the terminal applied when every layer passed. Setting it toallowrequires an explicit acknowledgement in config. This is the single place the product’s core guarantee lives.
Transforms decide how
Section titled “Transforms decide how”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_transformsrewrite 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
RequestResponderruns 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 carriesproxy-agent: bot-marshal. See ADR-0031. response_transformsrewrite 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.
Bodies stream by default
Section titled “Bodies stream by default”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.
Why interception is mandatory
Section titled “Why interception is mandatory”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.
What CONNECT can and cannot decide
Section titled “What CONNECT can and cannot decide”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.
The upstream guard
Section titled “The upstream guard”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.
Everything lands in the audit trail
Section titled “Everything lands in the audit trail”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.