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.comgithub.com,api.github.com,codeload.github.com,objects.githubusercontent.com,raw.githubusercontent.com,uploads.github.com,alive.github.comregistry.npmjs.orgpypi.org,files.pythonhosted.orgmise.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, nothttps://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
harnesseduser (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’segress: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 -nshows 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
| Piece | Location |
|---|---|
| Recipe egress field | egress: [host, …] in recipe.yaml (bare hostnames) |
| Recipe tools field | tools: [tool@version, …] in recipe.yaml (pinned) |
| Firewall script | catalog/base/egress-firewall.sh (default allowlist) |
| Disable for one run | --no-firewall / NO_FIREWALL=true |