Egress firewall & exposing services

Every harnessed container runs behind an egress firewall — an iptables allowlist that defaults to DROP and permits only a fixed set of hosts (Anthropic, GitHub, npm, PyPI, mise). This closes the primary exfiltration vector for an agent running your code. The tradeoff: a tool that needs to reach an outside service (Pulumi Cloud, an internal API, a package index) is blocked by default.

This guide covers how to conditionally open egress for a service — and, in the same recipe, install the CLI that uses it — without weakening the firewall for stacks that don’t need it.

The default allowlist

catalog/base/egress-firewall.sh sets iptables -P OUTPUT DROP and allows only:

  • api.anthropic.com, statsig.anthropic.com
  • github.com, api.github.com, codeload.github.com, objects.githubusercontent.com, raw.githubusercontent.com, uploads.github.com, alive.github.com
  • registry.npmjs.org
  • pypi.org, files.pythonhosted.org
  • mise.jdx.dev

Plus loopback, DNS, established connections, and the host gateway (so the pod can reach host services like the aws-sso ECS server or a service sidecar). Everything else is dropped. The rules are re-applied at each container session start.

Conditional exposure: recipe-declared egress:

Egress need is a property of a capability, not a global preference. So a recipe declares the hosts its tools reach; those hosts are added to the allowlist only when that recipe is in the stack. Remove the recipe and the firewall closes them again.

# catalog/recipes/pulumi/recipe.yaml
name: pulumi
description: Pulumi CLI + Pulumi Cloud access
egress:
  - api.pulumi.com     # Pulumi Cloud state backend + API
  - get.pulumi.com     # CLI/plugin downloads (if fetched at runtime)

Any stack composing recipes: [pulumi] gets those hosts allowed at launch. The domains are unioned across all recipes in the stack and passed to the firewall script, which resolves each to its current IPs and adds an ACCEPT rule.

Rules for egress: entries

  • Bare hostnames only — no scheme, path, port, or wildcard. api.pulumi.com, not https://api.pulumi.com/ and not *.pulumi.com. Invalid entries fail the build.
  • The firewall resolves the host to IPs via DNS at session start. A host behind a large, rotating CDN may return different IPs than a later connection uses; if a service is flaky behind the firewall, that is usually why — prefer a stable API hostname over a CDN edge.

Installing the tool too: recipe-declared tools:

A “expose a service” recipe usually also needs the service’s CLI. Declare pinned mise tools and harnessed installs them into the derived image as a single mise use -g layer — no Dockerfile required:

name: pulumi
description: Pulumi CLI + Pulumi Cloud access
tools:
  - pulumi@3.140.0     # installed via `mise use -g`
egress:
  - api.pulumi.com

That recipe is complete — pure YAML, no Dockerfile. harnessed build <stack> bakes the CLI and launch opens the egress.

Rules for tools: entries

  • Must be pinned to an explicit version (pulumi@3.140.0). A bare name (pulumi) or a floating ref (pulumi@latest) is rejected — same pin policy the recipe-Dockerfile lint enforces.
  • Anything mise can resolve works: a registered tool (pulumi@3.140.0), a GitHub-release backend (github:owner/repo@1.2.3), or an npm backend (npm:some-cli@1.0.0).
  • Tools are aggregated across all recipes in the stack into one install layer, run as the harnessed user (where mise’s globals live).

When to use a Dockerfile instead

tools: covers “install a pinned CLI.” If a recipe needs more — a build step, a system package, an ENV, a smoke test — keep using a recipe Dockerfile (see recipe authoring). The two compose: a recipe may declare tools: and ship a Dockerfile; both layers land in the derived image. Use egress: for the firewall regardless of which install path you pick.

Escape hatch: disable the firewall

For debugging you can turn the firewall off entirely for a launch:

harnessed my-stack --no-firewall        # or NO_FIREWALL=true harnessed my-stack

This is all-or-nothing and removes the exfiltration protection for that run — prefer a scoped egress: declaration for anything you intend to keep.

Troubleshooting

A tool inside the container can’t reach its service:

  • Confirm it’s the firewall. harnessed my-stack --no-firewall — if the call now works, the host was blocked. Re-add it to the recipe’s egress: rather than leaving the firewall off.
  • Check the host is in the recipe. The domain must be declared on a recipe that’s actually in the stack. podman exec <instance> iptables -L OUTPUT -n shows the active ACCEPT rules.
  • Watch for a resolve failure. The firewall logs could not resolve: <host> at session start if DNS failed for a declared host — check the spelling and that DNS itself is reachable.
  • Bare hostname? A build that rejects your egress: entry means it had a scheme/path/port — strip it down to the host.

Reference

PieceLocation
Recipe egress fieldegress: [host, …] in recipe.yaml (bare hostnames)
Recipe tools fieldtools: [tool@version, …] in recipe.yaml (pinned)
Firewall scriptcatalog/base/egress-firewall.sh (default allowlist)
Disable for one run--no-firewall / NO_FIREWALL=true