# Egress confinement

> Runs and environment builds reach the internet only through Ryph's egress proxy, and only the hosts on your allowlist.

An agent reads a lot of text it didn't choose: issues, comments, web pages, the READMEs of the packages it installs. Any of it can try to talk the agent into sending your code somewhere. Egress confinement gives that nowhere to go. A run has no route to the internet. Its one way out is Ryph's egress proxy, which lets it reach the hosts on your allowlist, such as the npm registry and PyPI, and refuses every other host.

Environment builds sit behind the same proxy. A build runs code from your repository, and an agent can change that code, so builds get the same boundary as runs.

A run installs from registry.npmjs.org, which the npm bundle allows. It then tries to upload the source to paste.example.net, which the proxy refuses, and to connect to an address directly, which has no route out. An environment build of the same repo installs a Rust toolchain from static.rust-lang.org, allowed by the rustup bundle, and is refused fixtures.acme-cdn.com.

A run and an environment build of the same repo, behind the same proxy. Hosts on the allowlist get through. Any other host is refused, and Ryph shows which one on the run or the build. A connection that goes around the proxy has nowhere to go.

Out of the box, the allowlist covers the common package registries and toolchain downloads, so installs and builds work with no setup.

## How a run reaches the internet

- **The run's network has no way out.** On Docker, each run sits on an internal network with no route to the internet, your local network or the host. On Kubernetes, network policies hold each run to the cluster's DNS, the Ryph services it needs and the egress proxy.
- **The proxy is the one way out.** Ryph points the run's proxy settings at it, so package managers, `curl` and git over HTTPS go through it as they would through a company proxy.
- **The run carries its allowlist.** When a run starts, Ryph signs its list of hosts into a token, and the proxy checks every connection against it. A change to the allowlist applies to runs that start after it.
- **The proxy checks host names.** It sees the host each connection asks for, on ports 80 and 443, and lets the connection through or refuses it. It doesn't decrypt anything, so it allows or refuses whole hosts.
- **An allowed name can't lead back inside.** The proxy refuses a host that resolves to a private, loopback or link-local address, cloud metadata services included. A public name pointed at your network gets nowhere.

## What's on the allowlist

The allowlist is made of bundles. Each names a registry or a download site, and covers all the hosts it uses, including the CDNs its downloads redirect to. All of them are on by default:

| Bundle              | Covers                                                                               |
| ------------------- | ------------------------------------------------------------------------------------ |
| npm                 | The npm registry, and Yarn's                                                         |
| Deno & JSR          | JSR, `deno.land` and Deno's releases                                                 |
| PyPI                | The Python package index and its files                                               |
| crates.io           | Rust's registry and crate downloads                                                  |
| Debian & Ubuntu     | `apt-get`: the archives, security updates and Launchpad PPAs                         |
| Go modules          | Go's module proxy and checksum database                                              |
| GitHub releases     | Release assets and source tarballs, from `github.com`                                |
| rustup              | Rust toolchains, for a pinned version the image doesn't have                         |
| Node.js releases    | Node.js downloads, as nvm, fnm, volta and corepack fetch them                        |
| mise                | mise's version index, and the GitHub releases and Helm downloads its tools come from |
| Playwright browsers | The browsers `playwright install` downloads                                          |

On top of the bundles, an administrator can add hosts by name, such as `api.acme.dev`, or `*.acme-cdn.com` for every name under a domain. A wildcard leaves out the domain itself, which needs its own entry.

## Who decides what a run can reach

**An administrator sets the allowlist for the deployment.** Open **Platform** and go to **Internet access**. Switch bundles on or off, and add hosts under **Additional hosts**. Until someone saves this page, the allowlist comes from `RYPH_EGRESS_BUNDLES` and `RYPH_EGRESS_HOSTS`, and with neither set, it's every bundle.

**An agent can be held to less.** Open **Agents**, pick the agent, click **Edit** and go to **Permissions**. Under **Internet access**, turn on **Narrow this agent's reach**, and switch on only what the agent needs. It applies to the whole run, the `setup.yml` steps at its start included. An agent can only be narrowed within the deployment's allowlist, and changing it in either direction takes an administrator. With everything switched off, the agent's runs reach no host at all, and installs fail.

**A repository has no say.** Nothing in `.ryph/`, or anywhere else in a repository, changes what a run can reach. The repository is the part an agent can edit, so it gets no way to ask for more.

## When a host is refused

The program that asked gets an error from the proxy. Package managers often report that only as a failed connection, so Ryph records every refusal where people look:

- **On the run.** Each refused host shows in the run's events. If the run fails, its outcome names the host and the bundle that would allow it, or says that no bundle covers it.
- **On the build, even when it succeeded.** A download that fails inside a build step often doesn't fail the build, and the image ends up without whatever the download was for. So the repository's **Environment** section says **The internet allowlist refused this build**, with the host, however the build ended.

To let a host through, an administrator switches on its bundle, or adds the host under **Internet access**. The next run or build can reach it.

## How builds are confined

An [environment](https://ryph.ai/docs/environments.md) build runs your repository's code: the `RUN` steps in `.ryph/Dockerfile` and every step in `.ryph/setup.yml`. An agent can change both. When it ships a patch that touches `.ryph/`, Ryph builds the patch's branch to show whether the image works. With an open internet in the build, an agent held in by its runs could write a download or an upload into `setup.yml` and have the build do it. So builds are confined like runs:

- **The same proxy.** A build gets a signed allowlist like a run's, and reaches the internet only through the egress proxy. BuildKit's own downloads, like the image a `.ryph/Dockerfile` starts `FROM`, go through it as well.
- **The deployment's allowlist.** One image serves every agent that works on the repository, so a build gets the deployment's whole allowlist, and an agent's narrowing doesn't apply to it. A narrowed agent's patch is built with more access than the agent's runs have. Set the repository to **Wait for approval**, and a patch is built only after someone approves it.
- **No credentials inside.** Ryph clones the repository before the build starts, and the build steps get the checkout without the git credential. The proxy token is all a build step is handed.
- **Refusals are reported** on the repository's **Environment** section, as above, and an agent waiting on its patch's build hears which hosts were refused.

### On Docker

Each build gets its own BuildKit, in a container on an internal network made for that build, with the run gate as the only other thing on it. Both are removed when the build ends. The `RUN` steps share that network, so they can't reach the internet, your local network or the host. Their one way out is the egress proxy, behind the run gate.

Builds stay off the network runs use. A build step runs as root, with more capabilities than a run gets, so it's kept apart from runs too.

Build steps can't look up names themselves. Ryph gives them the proxy by its address, and the proxy resolves host names, so a tool that ignores the standard proxy settings can't connect from a build.

The BuildKit container itself runs privileged, so an escape from a build is root on the Docker host. Connect only repositories whose `.ryph/` you'd run as root on that machine, or turn environments off.

If runs use a sandboxing runtime through `RYPH_DOCKER_RUNTIME`, such as gVisor, builds don't get it. The `ryph` binary then builds only the default branch, which holds code someone merged, and skips agents' branches. On Docker Compose, set `RYPH_ENV_TRUST_DEFAULT_BRANCH=on` on the launcher for the same behaviour. Without it, Ryph won't start with environments on.

### On Kubernetes

Builds run as Jobs in their own namespace, where network policies let a build reach the cluster's DNS, Ryph's forge and the egress proxy, and nothing more. They run rootless BuildKit, without a Kubernetes token. With `runs.runtimeClassName: gvisor` in the Helm values, each build gets its own kernel, as each run does.

Rootless BuildKit on Kubernetes runs build steps without their own process sandbox, so a step can reach the cloud identity the build pushes its image with. Builds of agents' branches use a separate identity, which can push only branch images, so a branch build can't touch the image the default branch builds.

## What else is held to the allowlist

- **Checks.** A [check](https://ryph.ai/docs/checks.md) suite runs your repository's commands, and is confined like a run: it reaches Ryph's forge and the egress proxy.
- **The agent's browser.** [Browser use](https://ryph.ai/docs/browser.md) loads every page through the run's egress proxy, so the browser reaches what the run may.
- **Previews.** A [preview](https://ryph.ai/docs/previews.md) carries a content security policy built from the run's allowlist, which holds the page's requests to the same hosts.

Some of what an agent does reaches the outside through Ryph's own services, each with its own controls. Model calls go through Ryph's [model proxy](https://ryph.ai/docs/model-proxy.md), forge requests through Ryph's [forge](https://ryph.ai/docs/secure-forge-interactions.md) and MCP tools through the [tool proxy](https://ryph.ai/docs/tool-proxy.md). Two are worth knowing about:

- **Web search and fetch** run in Ryph's web service, so the allowlist doesn't limit them. Fetch reads public HTTPS pages only. Remove the tools in an agent's tool policy, or turn them off for the deployment with `RYPH_WEB_TOOLS=off`.
- **Hosted MCP servers** reach the public internet, with private addresses refused.

## On your deployment

| Deployment                                      | Runs                                                               | Builds                                                        |
| ----------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------- |
| [On your machine](https://ryph.ai/docs/try-locally.md), confined | An internal network, with the proxy as the only way out            | Their own BuildKit on an internal network, as described above |
| On your machine, allowlisted                    | Proxy settings, with a direct route for anything that ignores them | The same as runs                                              |
| On your machine, direct                         | The open internet, with no allowlist                               | The same as runs                                              |
| [Docker Compose](https://ryph.ai/docs/docker-compose.md)         | Confined                                                           | Confined                                                      |
| [Networked Compose](https://ryph.ai/docs/networked-compose.md)   | Confined                                                           | Confined                                                      |
| [Kubernetes](https://ryph.ai/docs/kubernetes.md)                 | Confined by network policies, which the cluster has to enforce     | Confined by the builds namespace's network policies           |

On your machine, `ryph setup` asks how runs reach the internet, and offers **confined** wherever Docker supports it: Docker Desktop, or Docker Engine 28 or later on Linux. Podman isn't supported. On Kubernetes, check that your cluster's network plugin enforces network policies. Without that, the policies do nothing, and a run can go around the proxy.

`RYPH_EGRESS=open` turns the allowlist off on any deployment.

> [!WARNING]
> **Allowlisted is a convenience, and confined is the boundary**
>
> In allowlisted mode, package managers and setup steps follow the proxy settings, but the run keeps a direct route to the internet. Code an agent runs can ignore the settings and connect anywhere. Use it to try Ryph out, and choose confined for real work.

## What to keep in mind

- **An allowed host is open both ways.** The proxy allows a host for anything sent to it. npm and crates.io take a publish from whoever holds a token, and GitHub releases and mise open `github.com`, which takes a `git push` over HTTPS. Switch off bundles your repositories don't need.
- **The allowlist is per host.** The proxy doesn't see paths, so allowing `github.com` allows all of it.
- **A run's allowlist is fixed when it starts.** Its token stays valid for up to 24 hours, which `RYPH_EGRESS_TOKEN_TTL_MS` changes, and ending the run doesn't cancel it. Switching a bundle off protects the runs that start afterward.

How the proxy checks tokens and addresses is in [Egress and isolation](https://ryph.ai/docs/egress.md).
