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 raiseshttp:request. Both must be permitted. box.tomlbinds 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.
Allow a host
Section titled “Allow a host”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.
Bind a credential to a destination
Section titled “Bind a credential to a destination”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.
Choose where the credential goes
Section titled “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:
[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.
Sign AWS requests
Section titled “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/credentialsor~/.aws/config. Profiles that use SSO,role_arn,source_profile,web_identity_token_file, orcredential_processare 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 atCREDSD_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.
Write destination patterns
Section titled “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
Section titled “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:
@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
Section titled “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
Section titled “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
Section titled “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.