Skip to content

Allow network destinations and bind credentials

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.

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

@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.

Declare the binding in box.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. 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.

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

MethodConfigurationThe request carries
Bearer tokensecret.ref onlyAuthorization: Bearer <secret>
Custom headersecret.header = "x-api-key"x-api-key: <secret>
Custom prefixsecret.prefix = "token "Authorization: token <secret>
HTTP Basicsecret.placement = "basic_auth"Authorization: Basic …, with the secret as the user:password pair
Query parametersecret.placement = "query_param", secret.param = "key"?key=<secret>
AWS SigV4secret.ref = "aws://…" or "credsd://…"A SigV4 signature

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

[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

Section titled “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.

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.
[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.

Each destinations entry takes one of these forms:

PatternMatches
api.example.comThat host on port 443 or 80
api.example.com:8443That host on one port
api.example.com/v1Paths under /v1 on that host
api.example.com/*.jsonPaths ending in .json
*.example.comEvery 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.

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:

@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.*".

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

BodyCause
The policy’s denial message, naming the rulenet:connect or http:request refused the request.
blocked by egress controlA request to a bound destination lacks the placeholder, carries a different value, or can’t be signed.
credential not permitted on a plaintext requestA plain http:// request went to a bound destination.
request authority rejectedThe request inside a CONNECT named a different host.
response blocked by egress controlThe 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.

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.

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.