The network a program reaches
fetch exists inside the sandbox. outbound decides what it reaches, and you set it per execution. Its default is the open state, so a host that leaves it unset has handed the program the network the executor sits on.
Outbound takes three states
Section titled “Outbound takes three states”flowchart TB
guest["fetch() inside the isolate"]
guest --> setting{outbound}
setting -- "absent" --> native["the executor performs the request<br/><small>reaches whatever its network reaches</small>"]
setting -- "a function" --> yours["your function answers it<br/><small>request and response as plain data</small>"]
setting -- "null" --> closed["rejects with the declared text<br/><small>the network does not exist</small>"]
Omitting outbound is the default, and the default is open. From the machine it runs on, the executor performs the request itself, against whatever URL the program chose. No URL policy stands in between. That work counts against the run’s deadline, because the guest picked the target.
Passing a function is the state a host with a policy wants. Every request crosses your code as plain data, and every response comes back the same way. Passing null is the other explicit choice, and it closes the network for that execution. fetch rejects, and the text tells the model what remains possible:
Outbound fetch is disabled for this execution. Anything this run needs from outside has to arrive through a tool call.
Inside the sandbox that arrives as a TypeError. Network failures look like that to fetch on any platform, so a program with a try around its request takes the branch it already has:
await executor.execute({ code: `async () => { try { await fetch('https://example.com') } catch (failure) { return { name: failure.name, message: failure.message } } }`, outbound: null,}){ result: { name: 'TypeError', message: 'Outbound fetch is disabled for this execution. Anything this run needs from outside has to arrive through a tool call.' }, logs: []}Writing a policy
Section titled “Writing a policy”Your function receives the whole request as data and answers the whole response as data. Nothing streams in either direction: the request body arrives complete, and the response body is delivered at once.
type OutboundRequestMessage = { method: string url: string headers: Array<[string, string]> body: ArrayBuffer | null}
type OutboundResponseMessage = { status: number statusText: string headers: Array<[string, string]> body: ArrayBuffer}Calling fetch('https://api.internal/v1/items?q=hat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Trace': 'abc' }, body: '{"a":1}' }) inside the sandbox hands your function exactly this:
{ method: 'POST', url: 'https://api.internal/v1/items?q=hat', headers: [ [ 'Content-Type', 'application/json' ], [ 'X-Trace', 'abc' ] ], body: ArrayBuffer { [Uint8Contents]: <7b 22 61 22 3a 31 7d>, [byteLength]: 7 }}Header names arrive as the program wrote them, not lowercased, so a policy that matches on a name folds case itself. Every policy is a check, a call and a translation. Checking is the part that is easy to get wrong.
Allow lists have to survive a redirect
Section titled “Allow lists have to survive a redirect”Checking request.url and handing it to fetch is the obvious policy. But that does not hold. The Fetch standard gives a request the redirect mode follow unless something says otherwise, and node’s fetch takes that default like any browser would. So your check reads the first URL while the request lands wherever the chain ends, and any endpoint that can answer a 302 walks straight through:
// this is the shape that leaksoutbound: async (request) => { if (!allowed(request.url)) { throw new Error(`this run may not reach ${request.url}`) }
const answer = await fetch(request.url, { method: request.method, headers: request.headers, body: request.body, }) // …}Put two servers on loopback, let the allow list name only the first, and have its /bounce answer 302 Location: http://127.0.0.1:8081/. That policy refuses the direct call and serves the bounce:
// fetch('http://127.0.0.1:8081/') rejects: this run may not reach http://127.0.0.1:8081/// fetch('http://127.0.0.1:8080/bounce') answers: THE SECRET// fetch('http://127.0.0.1:8080/items') answers: ordinary answerRefuse to follow, and check every hop yourself:
const HOPS = 5
const allowed = (url: string): boolean => new URL(url).host === 'api.internal'
const forward = async (request: OutboundRequestMessage): Promise<OutboundResponseMessage> => { let url = request.url
for (let hop = 0; hop < HOPS; hop += 1) { if (!allowed(url)) { throw new Error(`this run may not reach ${url}`) }
const answer = await fetch(url, { method: request.method, headers: [...request.headers, ['authorization', `Bearer ${token}`]], body: request.body, redirect: 'manual', })
const location = answer.headers.get('location')
if (answer.status < 300 || answer.status > 399 || location === null) { return { status: answer.status, statusText: answer.statusText, headers: [...answer.headers], body: await answer.arrayBuffer(), } }
url = new URL(location, url).toString() }
throw new Error(`this run followed too many redirects from ${request.url}`)}Against the same two servers, that version refuses the direct call and the bounce alike and still serves the ordinary request. Watch the credential while you are in here. That authorization header is re-sent on every hop, so an allow list spanning more than one host adds it only for the hosts entitled to it. One cost comes with that wire shape. It carries method, url, headers and body and nothing else, so the program’s own redirect option never reaches your function. A program asking for redirect: 'manual' or redirect: 'error' gets whatever you decided. Chasing the chain yourself is the safe default, and a host that needs the guest’s intent carries it in a header of its own.
Throwing from your function reaches the program as a TypeError carrying your message. So a refusal reads to the model the way a network failure does, and it names the URL it was refused:
{ result: { name: 'TypeError', message: 'this run may not reach https://evil.example/x' }, logs: [] }Keep the allow list here, because the executor has none of its own. Keep the credential here too: a header you add never enters the isolate, and the program cannot read it back off a request it did not make. And if you want a record of what a program reached for, this is the single place every exchange passes through as plain data. Your function runs in your process in both arrangements. With the executor as a daemon, the request travels back over the same socket the program was sent on, and your policy and your credential stay on your machine while the program runs on someone else’s.
Response the program observes
Section titled “Response the program observes”Whatever your function returns becomes a real Response inside the isolate, carrying the status, the status text, the headers and the body you gave it. But the last three rows below are where it parts company with a browser, and a program may branch on any of them:
| What the program reads | What it gets |
|---|---|
answer.status, answer.statusText, answer.headers |
Exactly what your function returned |
answer.type |
Always default |
answer.redirected |
Always false |
answer.body |
One chunk carrying the whole answer |
Those three follow from the host performing the exchange, since the isolate never sees a redirect chain or a partial body. A request the program builds carries no Origin header, and a Headers object inside the sandbox keeps the names a browser forbids. So Host and Connection reach your function as the program wrote them. Each of these is stated with its reason in Runtime deviations. Sanitize what you forward.
Choosing an outbound state
Section titled “Choosing an outbound state”Close the network unless you have a reason not to. Give a program that needs the outside a tool instead. A tool is a named capability you wrote, and a URL is whatever the model composed. Hand outbound a function when your host has a policy: an allow list, a credential the program must never see, a request you want recorded. Leave it native when the executor already runs inside a network that is itself the boundary, and the operator has closed that network. But native mode follows a redirect chain wherever it leads, and it hands the program an answer that does not say where the bytes came from:
// http://127.0.0.1:8080/bounce redirects to a second server that answers 'THE SECRET'await executor.execute({ code: `async () => { const a = await fetch('http://127.0.0.1:8080/bounce'); return { url: a.url, redirected: a.redirected, body: await a.text() } }`,})// { result: { url: 'http://127.0.0.1:8080/bounce', redirected: false, body: 'THE SECRET' }, logs: [] }The threat model says what leaning on the surrounding network means. Operators hold a veto over that state. A daemon that denies native mode denies it however a caller asked for it, and the program meets the same rejection a null outbound gives. The operator guide carries that setting. A caller that supplied its own function, or that passed null, is untouched either way.
The run clock separates the two open states. A wait your function serves does not spend it, because you can bound your own function and the guest cannot make you slow. A request the executor performs natively does spend it, because the guest chose that URL. The bounds guide has the whole clock.
No second network surface exists
Section titled “No second network surface exists”A program refused by outbound has no second surface to try. WebSocket, XMLHttpRequest, EventSource and navigator.sendBeacon are all missing from the surface this runtime publishes, and all four read undefined. Dynamic import() of an http: or https: specifier resolves against the module map, not against the network:
await executor.execute({ code: `async () => { try { await import('https://esm.sh/lodash') } catch (failure) { return failure.message } }`, modules: {},})// { result: 'Module "https://esm.sh/lodash" is not in the module map. This request declared no modules.', logs: [] }That holds for esm.sh and for every other module CDN, because the resolver has one place to look. On every gate, a corpus of programs written to find another way out runs against these, under the method of the benchmark. The bounds guide says how large one request may be, and that bound applies in all three states. The sandbox chapter says how the setting crosses the wire, and why the state travels as an explicit mode string.
Outbound settles what a program may reach. Bounds says how long it may run and how much it may hold, and Modules hands it what to import instead of fetching it.