The agent’s and each tool’s network traffic goes through Box’s egress gateway. The box sets proxy variables for it, and the operating system blocks direct connections to other hosts. A tool or a stdio MCP server whose table sets `network.contain_egress = false` connects directly to IP hosts, without the gateway. You control the gateway in two places:

-   **The policy decides which requests leave.** Each connection raises `net:connect`, and each HTTP request raises `http:request`. Both must be permitted.
-   **`box.toml` binds credentials to destinations.** The workload holds a placeholder, and the gateway replaces it with the real secret on a permitted request.

An `[egress.<name>]` table doesn’t make a host reachable. Only the policy does. For how the gateway routes, decides, and forwards each request, read [How Box controls outbound traffic](https://github.com/strands-agents/box/blob/main/docs/design/egress.md).

## Allow a host

Permit the connection and the request. This pair allows read-only calls to the GitHub REST API:

```text
@id("github_connect")
permit (principal, action == Box::Action::"net:connect", resource)
when { context.input.host == "api.github.com" && context.input.port == 443 };

@id("github_read")
permit (principal, action == Box::Action::"http:request", resource)
when {
  context.input.host == "api.github.com" &&
  context.input.port == 443 &&
  context.input.method == "GET" &&
  context.input.path like "/repos/*"
};
```

`path` is the URL path without the query string. Hosts are lowercased, without a trailing dot.

The gateway decides `net:connect` for the host before it resolves it, and again for each address it dials. `context.input.ip` is the resolved address, as a string, and is absent from the first decision, so permit on `host` and `port`: a permit that needs `ip` never passes. Use `ip` in a `forbid`, guarded with `context.input has ip` and matched with `==` or `like`. An IPv6 address that carries an IPv4 address, such as `::ffff:169.254.169.254`, reads as that IPv4 address. Cedar’s `ipaddr` functions, such as `isInRange`, don’t apply, and Box refuses a policy that uses them.

The gateway decides `http:request` once per request, after it terminates TLS, so a rule sees the method and path of HTTPS traffic too.

A request the policy refuses gets an HTTP `403` response with the header `x-strands-box-egress: refused` and the denial message as the body.

## Bind a credential to a destination

Declare the binding in `box.toml`:

```toml
[egress.github]
destinations = ["api.github.com"]
secret.ref = "env://GITHUB_TOKEN"
```

When the box starts, Box reads `GITHUB_TOKEN` from the host OS’s shell that runs `box run`, and sets `GITHUB_TOKEN` inside the box to a placeholder such as `strands_box_9f2c…`. When a permitted request to `api.github.com` carries the placeholder where the binding puts the credential, by default `Authorization: Bearer`, the gateway replaces it with the real token. The box’s environment holds only the placeholder.

Export the variable before you start the box. An unset or empty variable stops the run with `credential variable GITHUB_TOKEN is not set, or holds only whitespace`.

Every process in the box gets every binding’s placeholder: the agent, each tool, and each stdio MCP server. The gateway can’t tell which process sent a request, so the box is the [credential boundary](https://github.com/strands-agents/box/blob/main/docs/design/decisions.md#the-box-is-the-credential-boundary). To keep a credential from a process, run that process in another box. A tool’s or stdio MCP server’s own `env` table can replace a placeholder, but a real value written there reaches that process unprotected.

### Choose where the credential goes

`secret.ref` names the source. The other `secret` keys choose where the gateway puts the value:

| Method | Configuration | The request carries |
| --- | --- | --- |
| Bearer token | `secret.ref` only | `Authorization: Bearer <secret>` |
| Custom header | `secret.header = "x-api-key"` | `x-api-key: <secret>` |
| Custom prefix | `secret.prefix = "token "` | `Authorization: token <secret>` |
| HTTP Basic | `secret.placement = "basic_auth"` | `Authorization: Basic …`, with the secret as the `user:password` pair |
| Query parameter | `secret.placement = "query_param"`, `secret.param = "key"` | `?key=<secret>` |
| AWS SigV4 | `secret.ref = "aws://…"` or `"credsd://…"` | A SigV4 signature |

For example, an API that takes a key in a header:

```toml
[egress.search]
destinations = ["api.search.example.com"]
secret = { ref = "env://SEARCH_API_KEY", header = "x-api-key" }
```

The gateway attaches a credential only over TLS. A plain `http://` request to a credential-bound destination is refused.

The placeholder starts with `strands_box_`, followed by 64 random hex characters. For a client that checks the format of its key, set `secret.phantom_prefix`, for example `"sk-ant-"`: 1 to 64 letters, digits, `-`, `_`, `.`, or `~`.

### Choose when the gateway attaches the secret

By default `secret.inject = "phantom"`: the gateway attaches the real secret only to a request that carries the box’s placeholder, and refuses any other request to the bound destination. `secret.inject = "always"` attaches the real secret to every request to the destinations, whether or not it carries the placeholder. A request without the placeholder is permitted with the credential injected: Box prints no warning for it, and its decision record shows an ordinary permit, with no note that the placeholder was missing. `inject` applies to `env://` sources only.

A client that sends its first request with no credential and waits for a `401`, such as `git` over HTTPS, fails under `"phantom"`: the gateway answers that first request with a `403`. Either configure the client to send the placeholder up front, such as `git -c http.extraHeader=…`, or set `"always"` on that binding.

## Sign AWS requests

Two sources sign requests with SigV4 instead of placing a secret. Neither takes `header`, `prefix`, `placement`, `param`, `inject`, or `phantom_prefix`:

-   **`aws://<profile>`** signs with the static access keys in that profile of `~/.aws/credentials` or `~/.aws/config`. Profiles that use SSO, `role_arn`, `source_profile`, `web_identity_token_file`, or `credential_process` are refused.
-   **`credsd://<environment>`** gets temporary session credentials from the Credentials Daemon (credsd). Before the workload starts, Box checks that the daemon answers and holds that environment, and stops the run if it doesn’t. Box finds the daemon’s socket at `CREDSD_SOCKET`, or the platform default.

```toml
[egress.aws]
destinations = ["*.us-west-2.amazonaws.com"]
secret.ref = "credsd://oncall"
```

The gateway derives the service and region from each request’s host, such as `logs.us-west-2.amazonaws.com`. Hosts outside `.amazonaws.com` and `.api.aws`, and host shapes the gateway can’t derive a service and region from, are refused. Every process in the box gets placeholder AWS keys, so the AWS CLI and SDKs build requests as usual.

## Write destination patterns

Each `destinations` entry takes one of these forms:

| Pattern | Matches |
| --- | --- |
| `api.example.com` | That host on port 443 or 80 |
| `api.example.com:8443` | That host on one port |
| `api.example.com/v1` | Paths under `/v1` on that host |
| `api.example.com/*.json` | Paths ending in `.json` |
| `*.example.com` | Every subdomain, not `example.com` itself |

A path prefix matches by whole segment: `api.example.com/v1/chat` matches `/v1/chat/completions` but not `/v1/chatbot`. Paths match case-sensitively, against the gateway’s canonical form of the path, and hosts match in lowercase.

A pattern that matches every host, such as `*`, is refused, and so are two patterns that overlap. A host that an http `[mcp.<name>]` server uses can’t appear in a route: put that server’s credential on its `[mcp.<name>]` table. Every `[egress.<name>]` needs a `secret`: a destination without a credential is a policy decision alone.

## Refuse cloud metadata endpoints

The gateway keeps no list of refused destinations: the policy decides every host and address, and Box writes no policy for you. Add these rules to every policy. They don’t cover a tool or MCP server with `network.contain_egress = false`, because its connections raise no `net:connect`:

```text
@id("metadata_hosts")
forbid (principal, action == Box::Action::"net:connect", resource)
when {
  context.input.host == "metadata.google.internal" ||
  context.input.host == "metadata.azure.internal"
};

@id("metadata_addresses")
forbid (principal, action == Box::Action::"net:connect", resource)
when {
  context.input has ip && (
    context.input.ip like "169.254.*" ||
    context.input.ip == "fd00:ec2::254" ||
    context.input.ip like "fe8*" || context.input.ip like "fe9*" ||
    context.input.ip like "fea*" || context.input.ip like "feb*")
};
```

The address rule is checked on each address the gateway dials, so a host name that resolves to a metadata address is refused too, and so are the IPv4-mapped, IPv4-compatible, 6to4, and Teredo forms of `169.254.*` addresses. Refuse private ranges the same way, for example with `context.input.ip like "10.*"`.

## Read a refusal

A request the gateway refuses gets an HTTP `403` with the header `x-strands-box-egress: refused`. The body says why:

| Body | Cause |
| --- | --- |
| The policy’s denial message, naming the rule | `net:connect` or `http:request` refused the request. |
| `blocked by egress control` | A request to a bound destination lacks the placeholder, carries a different value, or can’t be signed. |
| `credential not permitted on a plaintext request` | A plain `http://` request went to a bound destination. |
| `request authority rejected` | The request inside a `CONNECT` named a different host. |
| `response blocked by egress control` | The server sent a compressed response to a credentialed request. |

A `502` without the `x-strands-box-egress` header means the gateway couldn’t complete the exchange: it couldn’t resolve or reach the server, TLS to the server failed, or the response broke a size limit.

## What the gateway does to a response

On a credentialed request, the gateway asks the server for an uncompressed response, and replaces each copy of the real secret in the response headers and body with `[REDACTED]` before the process sees it. It refuses a compressed response to such a request, because it can’t search it for the secret.

## Protocol limits

The gateway forwards HTTP/1.1 only, one request per connection. Protocol upgrades, including WebSocket, are refused, and request and response bodies are capped at 16 MiB.