MITM Proxy
The MITM proxy is Capsem’s HTTPS inspection layer. The Network Engine terminates TLS from the guest, parses HTTP/DNS/model traffic, lifts it into typed Security Events, asks the Security Engine for a decision, applies validated rewrites or blocks, forwards allowed traffic to the real upstream, and records resolved telemetry to the session database.
Connection pipeline
Section titled “Connection pipeline”Each guest HTTPS connection flows through this pipeline:
graph TD
A["Guest connection<br/>vsock:5002"] --> B["Read metadata prefix<br/>(optional process name)"]
B --> C["TLS handshake<br/>MitmCertResolver captures SNI"]
C --> D["Read HTTP request<br/>method, path, headers, body"]
D --> E["Build http.request SecurityEvent"]
E --> F{"Security Engine decision"}
F -->|block| X["403 Forbidden<br/>+ resolved event"]
F -->|ask| X
F -->|rewrite| R["Validate/apply mutations"]
F -->|allow| H["Upstream TLS connection<br/>(cached per-connection)"]
R --> H
H --> I["Forward request"]
I --> J["Stream response to guest<br/>(inline SSE parsing for AI traffic)"]
J --> K["Emit resolved telemetry<br/>SecurityEvent + projections"]
The proxy uses hyper for HTTP parsing and tokio-rustls for TLS. Each vsock connection can carry multiple HTTP requests via keep-alive — upstream connections are cached per-connection to avoid re-establishing TLS for each request.
Configuration
Section titled “Configuration”graph LR
CA["CertAuthority<br/>(static CA keypair)"]
SEC["Security Engine<br/>(rules + detections + ask)"]
DB["DbWriter<br/>(async telemetry)"]
TLS["Upstream TLS config<br/>(webpki roots)"]
PRICE["PricingTable<br/>(embedded JSON)"]
TRACE["TraceState<br/>(multi-turn linking)"]
CA --> CFG["MitmProxyConfig"]
SEC --> CFG
DB --> CFG
TLS --> CFG
PRICE --> CFG
TRACE --> CFG
| Field | Type | Purpose |
|---|---|---|
ca | Arc<CertAuthority> | Static Capsem CA for leaf cert minting |
db | Arc<DbWriter> | Async telemetry writer to session.db |
upstream_tls | Arc<rustls::ClientConfig> | Shared TLS config with webpki root CAs |
telemetry | TelemetryDeps | Pricing, trace state, and canonical evidence writers |
pipeline | Arc<Pipeline> | Transport chunk processing and telemetry hooks |
mcp_endpoint | Option<Arc<McpEndpointState>> | Framed MCP endpoint for guest traffic |
Certificate authority
Section titled “Certificate authority”The proxy mints per-domain TLS certificates on-the-fly, signed by a static Capsem CA.
Cert minting flow
Section titled “Cert minting flow”sequenceDiagram
participant G as Guest
participant R as MitmCertResolver
participant CA as CertAuthority
participant C as Cache
G->>R: TLS ClientHello (SNI: github.com)
R->>C: Lookup github.com
alt Cache hit
C-->>R: Arc<CertifiedKey>
else Cache miss
R->>CA: mint_leaf("github.com")
CA-->>R: CertifiedKey [leaf, ca]
R->>C: Store in cache
end
R-->>G: TLS ServerHello + cert chain
Certificate parameters
Section titled “Certificate parameters”| Parameter | Value |
|---|---|
| Algorithm | ECDSA P-256 |
| Validity | 24 hours |
| Back-dating | 1 hour (clock skew tolerance) |
| SAN | DNS name of the target domain |
| Extended key usage | ServerAuth |
| Chain | [leaf, CA] (2 certificates) |
| CA key source | config/capsem-ca.key (committed, compile-time include_str!) |
Cache behavior
Section titled “Cache behavior”The cache uses double-checked locking: read lock for hits, write lock only on miss with a second check after acquiring the write lock. Concurrent requests for the same domain never mint duplicate certs.
Why the CA key is public
Section titled “Why the CA key is public”The MITM proxy CA private key is committed to the repository. This is intentional — the CA is only trusted inside Capsem’s own air-gapped VMs and has zero trust outside them. A public key provides transparency: anyone can verify there is no hidden interception. Per-installation key generation would reduce auditability.
Security Engine boundary
Section titled “Security Engine boundary”The Network Engine owns parsing and transmission. It does not own policy semantics. For each synchronous decision point it builds a typed SecurityEvent and expects one of four final actions from the Security Engine:
| Action | Network behavior |
|---|---|
allow | Forward the request or response unchanged. |
ask | Pause/fail closed until the confirm path resolves the decision. |
block | Stop transmission and return the protocol-appropriate denial. |
rewrite | Apply only validated declarative mutations to allowlisted fields. |
The resolved event records the input, matched rule/finding ids, final decision, allowed mutations, and attribution before telemetry/log projections are written.
HTTP enforcement
Section titled “HTTP enforcement”Profile-owned enforcement rules provide request and response control. Rules use
canonical policy roots such as http.request.host, http.request.url,
http.request.path, http.request.header("authorization").exists(), and
http.request.body.text.contains("secret"). Authored rules do not target
internal event.* fields.
| Subject field | Example use |
|---|---|
http.request.host | Block a specific host or suffix. |
http.request.method | Block write methods such as POST or DELETE. |
http.request.path | Match repository, API, or organization paths. |
http.request.url | Match the full normalized URL. |
http.request.header(name) | Match, require, or strip request headers. |
http.response.status | Match upstream status on response policy. |
Example:
[security.rules.http.block_openai_github]on = "http.request"if = 'http.request.host == "github.com" && http.request.path.startsWith("/openai")'decision = "block"priority = 10Header stripping is a rewrite rule and runs before the stripped headers are
forwarded or captured in telemetry:
[security.rules.http.strip_auth]on = "http.request"if = 'http.request.host == "api.example.com"'decision = "rewrite"priority = 20strip_request_headers = ["authorization", "x-api-key"]AI traffic handling
Section titled “AI traffic handling”For AI provider domains, the proxy parses SSE response streams inline to extract structured telemetry.
Provider detection
Section titled “Provider detection”| Domain | Provider | API paths |
|---|---|---|
api.anthropic.com | Anthropic | /v1/messages |
api.openai.com | OpenAI | /v1/responses, /v1/chat/completions |
generativelanguage.googleapis.com | /v1beta/* |
SSE parsing pipeline
Section titled “SSE parsing pipeline”graph LR
A["HTTP response body<br/>(chunked)"] --> B["AiResponseBody<br/>(hyper Body wrapper)"]
B --> C["SseParser<br/>(stateful wire format)"]
C --> D["ProviderStreamParser<br/>(Anthropic/OpenAI/Google)"]
D --> E["Vec<LlmEvent><br/>(accumulated)"]
E --> F["collect_summary()<br/>(pure function)"]
F --> G["StreamSummary<br/>(text, tools, tokens, cost)"]
Parsing runs inline during poll_frame() — response bytes pass through unchanged to the guest with zero added latency.
Normalized event types
Section titled “Normalized event types”| Event | Fields | Description |
|---|---|---|
MessageStart | message_id, model | Stream began |
TextDelta | index, text | Incremental text output |
ThinkingDelta | index, text | Reasoning/chain-of-thought output |
ToolCallStart | index, call_id, name | Model invoked a tool |
ToolCallArgumentDelta | index, delta | Incremental tool call JSON arguments |
ToolCallEnd | index | Tool call arguments complete |
ContentBlockEnd | index | Content block finished |
Usage | input_tokens, output_tokens, details | Token usage update (details: cache_read, thinking, etc.) |
MessageEnd | stop_reason | Stream finished (EndTurn, ToolUse, MaxTokens, ContentFilter) |
Unknown | event_type, raw | Unrecognized SSE event (logged, not parsed) |
Tool call origin classification
Section titled “Tool call origin classification”| Origin | Criteria | Example |
|---|---|---|
native | Default for tool names without __ | write_file, bash |
local | Matches is_builtin_tool() | fetch_http, grep_http, http_headers |
mcp_proxy | Name contains __ (MCP namespace separator) | github__list_repos |
Cost estimation
Section titled “Cost estimation”Model pricing is loaded from config/genai-prices.json (embedded at compile time via include_str!). Cost = (input_tokens * input_price + output_tokens * output_price). Updated via just update_prices.
Trace state correlation
Section titled “Trace state correlation”The TraceState tracks multi-turn agent conversations across request/response cycles:
sequenceDiagram
participant Agent
participant Proxy
participant State as TraceState
Agent->>Proxy: Request (no tool_responses)
Note over Proxy: New trace_id = UUID
Proxy->>State: Register tool call_ids
Proxy-->>Agent: Response (stop: ToolUse, calls: [A, B])
Agent->>Proxy: Request (tool_responses for A, B)
Proxy->>State: Lookup call_ids [A, B]
Note over State: Found trace_id from previous turn
Proxy->>State: Register new call_ids [C]
Proxy-->>Agent: Response (stop: ToolUse, calls: [C])
Agent->>Proxy: Request (tool_responses for C)
Proxy->>State: Lookup call_id [C]
Proxy-->>Agent: Response (stop: EndTurn)
Proxy->>State: Complete trace (cleanup)
All model_calls rows in the same trace share a trace_id, enabling per-turn cost and token aggregation.
Telemetry emission
Section titled “Telemetry emission”Telemetry is emitted asynchronously after the response body completes (not during streaming):
| Event type | When | Data |
|---|---|---|
SecurityEvent | Every enforced HTTP/model decision | Event family/type, subject, context, findings, decision, mutations, attribution |
NetEvent projection | Every HTTP request | Domain, method, path, status, bytes, latency, final decision, body previews |
ModelCall projection | AI provider requests only | Provider, model, tokens, cost, tool calls, text content, trace_id |
The TelemetryBody wrapper around the hyper response body triggers tokio::spawn(emitter.emit()) when the body stream reaches EOF.
Performance
Section titled “Performance”| Optimization | Mechanism |
|---|---|
| Connection reuse | Upstream reqwest sender cached per-connection for keep-alive |
| TLS session reuse | Shared rustls::ClientConfig with webpki roots |
| Cert caching | Double-checked locking; each domain minted once |
| Inline parsing | SSE parsing runs in poll_frame(), zero-copy passthrough |
| Async telemetry | DB writes happen on a dedicated thread; never blocks the proxy |
| Compiled rule snapshots | Arc clone per request avoids holding registry locks during I/O |
Key source files
Section titled “Key source files”| File | Purpose |
|---|---|
capsem-core/src/net/mitm_proxy.rs | Connection handling, HTTP forwarding, telemetry emission |
capsem-core/src/net/cert_authority.rs | CA loading, leaf cert minting, cache |
crates/capsem-security-engine/ | SecurityEvent decisions, CEL/Sigma matching, resolved-event evidence |
capsem-core/src/net/mitm_proxy/ | HTTP/model SecurityEvent projection and proxy pipeline |
capsem-core/src/net/ai_traffic/ | SSE parsing, provider parsers, events, pricing |
capsem-core/src/net/ai_traffic/mod.rs | TraceState for multi-turn linking |
config/capsem-ca.key, config/capsem-ca.crt | Static ECDSA P-256 CA keypair |