Outcall
Specifications011-tls-interception

011-tls-interception

Specification module 011-tls-interception

S011: TLS Interception (optional)

FieldValue
SpecS011
FeatureTLS Interception (optional MITM)
Date2026-05-05
StatusDraft
Author@marktopper

Overview

Outcall's HTTP proxy (S006) does not decrypt HTTPS by default — it makes verdicts on the CONNECT hostname and the TLS SNI, then tunnels the encrypted bytes verbatim. That preserves end-to-end confidentiality but limits HTTPS matching to host and port.

Some operators need more. To enforce per-method, per-path, or per-payload policy on HTTPS endpoints, the proxy must terminate TLS, evaluate the plaintext request against the rule engine, then re-encrypt and forward.

This spec defines an opt-in, per-rule TLS interception mode. It is off by default. When a rule sets egress.mode: intercept, the proxy performs MITM on requests matching that rule using a CA the operator provisioned and the agent container trusts. Every other rule continues to operate at SNI level (S006).

Why opt-in, per-rule

TLS interception trades end-to-end encryption for visibility. The cost is real:

  • Pinned hosts will reject the proxy. Many SaaS providers pin certificates; intercepting them returns TLS errors to the agent.
  • mTLS breaks. Client certificates terminate at the proxy.
  • PII enters the proxy. Request and (optionally) response bodies are decrypted, which expands the trust boundary of the daemon.
  • CA key compromise = trust compromise. A leaked CA key lets anyone impersonate every site the agent talks to.

Because of these costs, interception is per-rule, off by default, and gated by an explicit --ca-cert / --ca-key daemon flag. Operating without the flag disables the mode entirely — a rule that requests mode: intercept against a daemon with no CA loaded fails validation at outcall rules reload.

How it works

  1. Operator initialises a CA: outcall ca init --out /etc/outcall/ca/. This produces ca.crt and ca.key. The CA cert is the trust anchor the agent container must install.

  2. Operator starts outcalld with --ca-cert /etc/outcall/ca/ca.crt --ca-key /etc/outcall/ca/ca.key. The proxy now has the material to sign per-host leaf certificates on demand.

  3. Operator authors a rule with egress.mode: intercept:

    version: "1"
    rules:
      - id: anthropic-messages-only
        condition: |
          http.host == "api.anthropic.com" &&
          http.method == "POST" &&
          http.path.startsWith("/v1/messages")
        action: allow
        egress:
          mode: intercept
  4. Agent container is launched with the CA cert mounted into its trust store (e.g. -v /etc/outcall/ca/ca.crt:/usr/local/share/ca-certificates/outcall.crt:ro on Debian/Ubuntu, then update-ca-certificates). For Python, Node, Go, and curl, this is the standard CA path; for languages with their own bundles (e.g. Python's certifi), SSL_CERT_FILE is the override.

  5. When the agent makes an HTTPS request whose CONNECT hostname matches an intercept rule:

    • Proxy evaluates the rule on the CONNECT hostname (S006 path).
    • Proxy sends 200 Connection Established.
    • Proxy generates a leaf certificate for the hostname, signed by the CA, valid for 24h (cached in-memory keyed by hostname).
    • Proxy performs a real TLS handshake with the agent, presenting the leaf cert and negotiating ALPN.
    • Proxy opens its own TLS connection to upstream, negotiating the same ALPN.
    • Proxy reads the decrypted request, populates a full HTTP context (http.method, http.path, http.headers.*, http.body_size, optionally http.body), evaluates the rule engine.
    • On ALLOW: re-encrypts and forwards. On BLOCK: returns 403 over the established TLS session.
  6. For rules that do not opt into interception, S006 behaviour is unchanged. The same proxy listens on the same port; the dispatch is per-rule.

Tech stack

  • rcgen to generate the CA-signed leaf certificates per hostname
  • rustls for both client-side TLS (proxy ↔ agent) and upstream TLS (proxy ↔ origin), with HTTP/1.1 and HTTP/2 ALPN
  • hyper for HTTP/1.1 and HTTP/2 request parsing on the decrypted side
  • An LRU cache (max ~1024 hostnames) for generated leaf certs

User Scenarios

S011-US-001 [P2] As a host operator, I want to allow specific HTTPS API methods and paths only, so I can enforce least-privilege access to APIs my agents call.

S011-US-002 [P2] As a host operator, I want to opt into TLS interception per rule, not globally, so I can keep most traffic untouched and only decrypt where necessary.

S011-US-003 [P2] As a host operator, I want a CLI command to generate a fresh CA, so I do not need an external PKI tool to get started.

S011-US-004 [P2] As a host operator, I want the daemon to refuse to load intercept rules when no CA is configured, so misconfiguration cannot silently degrade to default-allow.

S011-US-005 [P3] As a host operator, I want to inspect or block on request body content (e.g. forbid certain shell commands inside an HTTPS POST body), so I can catch egress that hides intent in the payload.

S011-US-006 [P2] As a host operator, I want clear feedback when interception fails (e.g. pinned host, ALPN mismatch), so I can diagnose without packet capture.

S011-US-007 [P3] As a host operator, I want a way to obtain the CA bundle from the daemon for distribution to containers, so I do not have to copy files between hosts manually.

Requirements Summary

IDTypePriorityTitleStatus
S011-FR-001FunctionalP2CA load via daemon flagsDraft
S011-FR-002FunctionalP2CA initialisation CLI commandDraft
S011-FR-003FunctionalP2Per-rule egress.mode: interceptDraft
S011-FR-004FunctionalP2Validation: intercept requires CADraft
S011-FR-005FunctionalP2Leaf certificate generationDraft
S011-FR-006FunctionalP2Leaf certificate cache (LRU + TTL)Draft
S011-FR-007FunctionalP2Client-side TLS handshakeDraft
S011-FR-008FunctionalP2Upstream TLS handshakeDraft
S011-FR-009FunctionalP2ALPN negotiation parityDraft
S011-FR-010FunctionalP2HTTP/1.1 request parsing on decrypted sideDraft
S011-FR-011FunctionalP2HTTP/2 request parsing on decrypted sideDraft
S011-FR-012FunctionalP2CEL context: full http.* fieldsDraft
S011-FR-013FunctionalP3CEL context: optional http.bodyDraft
S011-FR-014FunctionalP2Body buffer cap (default 1 MiB)Draft
S011-FR-015FunctionalP2BLOCK verdict response within TLSDraft
S011-FR-016FunctionalP2ALLOW verdict re-encryption + forwardDraft
S011-FR-017FunctionalP2Fail-closed on interception errorDraft
S011-FR-018FunctionalP2CA cert bundle CLI exportDraft
S011-FR-019FunctionalP2Structured logging of intercepted requestsDraft
S011-FR-020FunctionalP3Per-rule body matching opt-inDraft
S011-FR-021FunctionalP2Configurable leaf cert TTLDraft
S011-FR-022FunctionalP2Backwards compatible: no CA → no behaviour changeDraft
S011-AS-001AcceptanceP2Method-scoped HTTPS rule allows POST and blocks GETDraft
S011-AS-002AcceptanceP2Path-scoped HTTPS rule allows /v1/x and blocks /v1/yDraft
S011-AS-003AcceptanceP2Rule with mode: intercept rejected when no CA loadedDraft
S011-AS-004AcceptanceP2Daemon starts without --ca-cert (interception disabled)Draft
S011-AS-005AcceptanceP2Pinned upstream returns clear error to agentDraft
S011-AS-006AcceptanceP2Leaf cert cached across requests to same hostDraft
S011-AS-007AcceptanceP3Body matcher allows JSON-payload ruleDraft
S011-AS-008AcceptanceP2Non-intercept rule on same daemon unchangedDraft
S011-AS-009AcceptanceP2outcall ca init produces 4096-bit RSA CA valid 10 yearsDraft
S011-AS-010AcceptanceP2outcall ca bundle emits PEM to stdoutDraft
S011-IF-001InterfaceP2Daemon flag --ca-cert <path>Draft
S011-IF-002InterfaceP2Daemon flag --ca-key <path>Draft
S011-IF-003InterfaceP2Daemon flag --intercept-leaf-ttl-secs <n>Draft
S011-IF-004InterfaceP2Daemon flag --intercept-body-cap-bytes <n>Draft
S011-IF-005InterfaceP2Rule schema: egress.mode: interceptDraft
S011-IF-006InterfaceP3Rule schema: egress.match_body: trueDraft
S011-IF-007InterfaceP2CLI: outcall ca init [--out <dir>]Draft
S011-IF-008InterfaceP2CLI: outcall ca bundleDraft
S011-IF-009InterfaceP2CLI: outcall ca statusDraft
S011-IF-010InterfaceP2CEL context: http.method, http.path, http.headers.*, http.body_sizeDraft
S011-IF-011InterfaceP3CEL context: http.body (string, present when body matching enabled)Draft
S011-EC-001Edge CaseP2Upstream pins certs (HSTS/HPKP) — agent gets TLS errorDraft
S011-EC-002Edge CaseP2ALPN mismatch (agent wants h2, upstream offers http/1.1)Draft
S011-EC-003Edge CaseP2Agent does not trust CA — handshake fails locallyDraft
S011-EC-004Edge CaseP2CA key on disk is unreadableDraft
S011-EC-005Edge CaseP2Body exceeds buffer capDraft
S011-EC-006Edge CaseP3Streaming response (chunked / SSE)Draft
S011-EC-007Edge CaseP3WebSocket upgrade over intercepted TLSDraft
S011-EC-008Edge CaseP2Cache eviction during in-flight handshakeDraft
S011-EC-009Edge CaseP2Client uses TLS 1.2 onlyDraft
S011-EC-010Edge CaseP3mTLS (client cert) request — reject with documented errorDraft
S011-EC-011Edge CaseP2Daemon shutdown with active intercepted tunnelsDraft
S011-EC-012Edge CaseP3HTTP/3 / QUIC — out of scope, document explicitlyDraft
S011-SC-001SuccessP2Decrypted method/path enforced for HTTPS endpointDraft
S011-SC-002SuccessP2Default-off: daemon without CA preserves S006 behaviour exactlyDraft
S011-SC-003SuccessP2Cache prevents re-signing on every requestDraft
S011-SC-004SuccessP2Rule validation rejects intercept without CADraft
S011-SC-005SuccessP2CA initialisation CLI produces a working CADraft
S011-SC-006SuccessP3Body matching catches a known payload patternDraft
S011-SC-007SuccessP2Interception logs include matched rule and outcomeDraft
S011-SC-008SuccessP2Pinned host failure surfaces a clear operator-facing errorDraft

Out of Scope

  • HTTP/3 / QUIC interception — UDP, separate handshake protocol, not handled here.
  • Default-on interception — interception is always per-rule and opt-in.
  • Response body inspection — only request bodies (and optionally), to keep memory and latency bounded.
  • Custom CA chains / intermediates — v1 supports a single root CA; intermediate chains are a future extension.
  • Hardware key storage (HSM, PKCS#11) — v1 reads key material from disk; HSM integration is a future extension.
  • TLS interception of non-HTTP protocols — only HTTP/1.1 and HTTP/2 over TLS.
  • mTLS bridging — interception breaks client certs by definition; this spec rejects mTLS handshakes with a documented error rather than attempting to bridge.

Cross-Spec Dependencies

  • Depends on: S003 (extended CEL context for decrypted requests)
  • Depends on: S006 (this spec extends the proxy's CONNECT path)
  • Depends on: S007 (DNS filter still gates which hostnames resolve at all)

Shared Types (outcall-api)

Constants

pub const INTERCEPT_LEAF_TTL_SECS_DEFAULT: u64 = 86_400;       // 24h
pub const INTERCEPT_BODY_CAP_BYTES_DEFAULT: usize = 1_048_576; // 1 MiB
pub const INTERCEPT_LEAF_CACHE_MAX: usize = 1024;

Types

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaConfig {
    pub cert_path: PathBuf,
    pub key_path: PathBuf,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum EgressMode {
    Proxy,      // S006: SNI-only
    DirectIp,   // S009: nftables verdict
    Intercept,  // S011: TLS termination + decrypted L7
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InterceptConfig {
    pub leaf_ttl_secs: u64,
    pub body_cap_bytes: usize,
    pub match_body: bool,        // per-rule opt-in (FR-020)
}

#[derive(Debug, Clone)]
pub struct InterceptedRequestContext {
    pub hostname: String,
    pub method: String,
    pub path: String,
    pub headers: HashMap<String, String>,
    pub body_size: usize,
    pub body: Option<String>,    // present when match_body=true and ≤ cap
}

On this page