Outcall
Operator guides

Rules

Writing rules

Rules are YAML files in /etc/outcall/rules.d/. Every file may declare a list of rules; the daemon concatenates them, in filename order, into one active rule set.

Anatomy

version: "1"
rules:
  - id: allow-openai
    description: "agent may call the OpenAI API only"
    condition: 'dns.query == "api.openai.com" || http.host == "api.openai.com"'
    action: allow
    egress:
      mode: proxy
FieldRequiredPurpose
versionyesYAML schema version. Currently "1".
idyesUnique within the active rule set. Used in logs and outcall rules show.
descriptionnoFree-form. Surface this in dashboards.
conditionyesA CEL expression. See matchers.
actionyesallow or block. Default action when no rule matches is block.
egressnoPer-rule egress configuration when action: allow.

Matchers

Conditions are written in CEL. The rule engine evaluates the condition against a context object whose shape depends on the layer that asked for a verdict.

DNS context

FieldTypeExample
dns.querystring"api.openai.com"
dns.typestring"A", "AAAA", "CNAME"

HTTP context

Outcall does not decrypt HTTPS — there is no TLS interception, no CA, no MITM. What the rule engine sees depends on whether the request is plaintext HTTP or tunnelled HTTPS:

FieldPlaintext HTTPHTTPS (CONNECT + SNI)
http.hostHost headerCONNECT host, then SNI from the TLS ClientHello
http.methodactual method (GET, POST, …)always "CONNECT"
http.pathactual pathalways "/"
http.scheme"http""https"

Practical consequence: filtering HTTPS by method or path is not possible without TLS termination, and Outcall does not terminate TLS. To restrict an HTTPS service, match on http.host (and optionally dns.query) only. To restrict by method or path, the traffic must be plaintext HTTP — usually inside a controlled internal network.

Network context (raw L3/L4)

FieldTypeExample
net.dst_ipstring"140.82.121.4"
net.dst_portint443
net.protocolstring"tcp", "udp", "icmp"

Agent context

FieldTypeExample
agent.container_idstring"d4a1c5..."
agent.imagestring"ghcr.io/example/agent:1.2.3"

You can mix contexts in a single rule. The engine surfaces only the fields relevant to the layer asking for a verdict — references to absent fields evaluate to null and short-circuit &&/|| correctly.

Actions

action: allow
egress:
  mode: proxy            # enforce at L7 via the HTTP proxy
  ports: [443]           # required when mode is direct_ip
ModeBehaviour
proxy (default)Allow only via the HTTP proxy. SNI/Host enforced at L7. No TLS decryption. Recommended for most rules.
direct_ipInsert a per-rule nftables accept for each resolved IPv4/IPv6 of the matching DNS query. Use only when the agent uses raw sockets that can't transit the proxy.
interceptOptional, off by default. Terminate TLS at the proxy using an operator-provided CA so the rule engine can match on http.method, http.path, and (optionally) http.body. Requires --ca-cert / --ca-key on the daemon and the CA installed in the agent's trust store. See TLS interception below.

direct_ip defaults to ports [80, 443] when omitted.

TLS interception (mode: intercept)

For most rules, mode: proxy is the right answer — you can match HTTPS by hostname (CONNECT host + SNI), and the agent's TLS session is preserved end-to-end.

When you genuinely need to enforce policy on the contents of an HTTPS request — only allow POST /v1/messages against api.anthropic.com, or reject a JSON body that contains a forbidden field — you need the proxy to decrypt. That's what mode: intercept gives you, with explicit trade-offs:

  • You provision a CA. outcall ca init produces a fresh root CA. The cert is the trust anchor; the key signs per-host leaf certs the proxy presents to the agent. The key is sensitive material — store it as you'd store any private key.
  • The agent must trust the CA. Mount the cert into the container's trust store (/usr/local/share/ca-certificates/outcall.crt on Debian/Ubuntu, then update-ca-certificates). For Python's certifi bundle, use SSL_CERT_FILE.
  • Pinning breaks. Hosts that pin certificates at the application layer (Google services, some banks) will reject the proxy's leaf cert. Document those hosts and use mode: proxy for them.
  • mTLS breaks. A client cert presented by the agent terminates at the proxy. mTLS-protected upstreams must use mode: proxy.
  • Bodies enter the daemon's memory. When match_body: true, the request body up to the configured cap (default 1 MiB) is buffered in the proxy. Sensitive bodies should be considered visible to the daemon.

Setup

# 1. Generate a CA (once per host).
outcall ca init --out /etc/outcall/ca/

# 2. Restart outcalld with the CA flags.
outcalld \
  --bridge outcall0 \
  --ca-cert /etc/outcall/ca/ca.crt \
  --ca-key  /etc/outcall/ca/ca.key

# 3. Confirm the CA is loaded.
outcall ca status

# 4. Distribute the CA cert to your container build (or mount at runtime).
outcall ca bundle > /etc/outcall/ca/ca.pem

Enabling on a rule

version: "1"
rules:
  - id: anthropic-messages-only
    description: "agent may POST messages, nothing else on this host"
    condition: |
      http.host == "api.anthropic.com" &&
      http.method == "POST" &&
      http.path.startsWith("/v1/messages")
    action: allow
    egress:
      mode: intercept

A rule with mode: intercept is rejected at reload if no CA is loaded — the daemon refuses the whole rule set rather than silently degrading.

Inspecting payloads

When you need to match on body contents, opt in per rule:

- id: openai-no-system-override
  description: "block prompts that try to override the system role"
  condition: |
    http.host == "api.openai.com" &&
    http.method == "POST" &&
    http.body != null &&
    !http.body.contains('"role":"system"')
  action: allow
  egress:
    mode: intercept
    match_body: true

http.body is null when:

  • The rule does not set match_body: true.
  • The body exceeds --intercept-body-cap-bytes (default 1 MiB).
  • The body fails UTF-8 decode (lossy replacement is attempted first).

Always test for nullness before string operations: a rule with http.body.contains(...) and a non-text payload would error otherwise.

The full spec — every flag, every error code, every edge case — is in S011: TLS Interception.

Examples

Allow GitHub clone (HTTPS), nothing else

version: "1"
rules:
  - id: allow-github-https
    condition: |
      dns.query == "github.com" ||
      http.host == "github.com"
    action: allow

Method matching (http.method == "GET") cannot be enforced through the HTTPS proxy — the encrypted tunnel hides it. Allow the host, accept that the agent could in principle make any HTTPS verb to it.

Allow the npm registry

version: "1"
rules:
  - id: allow-npm
    condition: 'http.host == "registry.npmjs.org"'
    action: allow

The proxy already enforces HTTPS in practice — DNS only resolves what the rule set allows, and registry.npmjs.org only serves TLS. No http.scheme filter needed.

Block everything from a specific image

version: "1"
rules:
  - id: deny-untrusted-image
    condition: 'agent.image.startsWith("ghcr.io/legacy/")'
    action: block

Allow a tightly-scoped path (plaintext HTTP only)

This pattern only works for plaintext HTTP. For an HTTPS API like api.anthropic.com, method and path are sealed inside the TLS tunnel and cannot be matched.

version: "1"
rules:
  - id: allow-internal-metrics
    condition: |
      http.scheme == "http" &&
      http.host == "metrics.internal" &&
      http.method == "GET" &&
      http.path.startsWith("/v1/metrics")
    action: allow

Authoring workflow

  1. Edit a file in /etc/outcall/rules.d/.
  2. Run outcall rules reload --dry-run to validate.
  3. Run outcall rules reload to swap the active set.
  4. Run outcall rules counters after a few minutes to confirm the rule is actually firing.

A rule that compiles but never matches is almost always wrong — the agent is either bypassing it (DNS instead of HTTP), or you've over-scoped the condition.

Pitfalls

  • Wildcards: there's no *.openai.com. Use CEL string predicates: http.host.endsWith(".openai.com") && http.host != "evil.openai.com.attacker".
  • Empty rule files are rejected — version: "1" with no rules: is a parse error, on the assumption that you meant to write something.
  • block is implicit. You don't need a catch-all block rule — the default verdict is block. Adding one anyway is fine and surfaces in counters.

See Edge cases for the exhaustive list of corner-case behaviours.

On this page