Skip to content

Network

fetch exists inside the sandbox. outbound decides where it can go, and you set it per execution. Leave it unset and the program gets the executor’s own network.

flowchart TB
  guest["fetch() inside the isolate"]
  guest --> setting{outbound}
  setting -- "absent" --> native["the executor performs the request<br/><small>from its own machine, to any URL its network reaches</small>"]
  setting -- "a function" --> yours["your function returns the response<br/><small>request and response as plain data</small>"]
  setting -- "null" --> closed["fetch rejects with the declared text<br/><small>no network at all</small>"]

Omitting outbound selects the native state, and the executor performs the request itself, against whatever URL the program composed. Nothing checks that URL, so the network around the executor is the only bound on where a run can go. 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. 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: []
}

Your function receives the whole request as data and returns the whole response as data. A request body arrives complete and a response body is delivered at once, so neither direction streams.

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
}
}

Content-Type and X-Trace arrive as the program wrote them, not lowercased, so fold case yourself in a policy that matches on a name. Repeated names arrive combined into one pair, so two x-tag appends inside the sandbox come through as [ [ 'x-tag', 'one, two' ] ].

Checking request.url and handing it to fetch is the obvious policy, and it leaks. Fetch 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 and the request goes wherever the chain ends: a 302 from an endpoint on your list sends it anywhere at all.

const allowed = (url: string): boolean => new URL(url).host === '127.0.0.1:8080'
// the shape that leaks
const leaking = async (request: OutboundRequestMessage): Promise<OutboundResponseMessage> => {
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,
})
return {
status: answer.status,
statusText: answer.statusText,
headers: [...answer.headers],
body: await answer.arrayBuffer(),
}
}

Put two servers on loopback, and have /bounce on the allowed one return 302 Location: http://127.0.0.1:8081/. leaking turns down the direct call. The bounce brings the secret back anyway:

// 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 answer

Refuse to follow, and check every hop yourself:

const HOPS = 5
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, forward turns down the direct call and the bounce alike. The ordinary request still passes through:

// 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') rejects: this run may not reach http://127.0.0.1:8081/
// fetch('http://127.0.0.1:8080/items') answers: ordinary answer

redirect: 'manual' makes the loop possible, and HOPS stops a chain that never ends. But forward re-sends the authorization header on every hop, so where your allow list spans more than one host, add that header only for the hosts entitled to it. OutboundRequestMessage has method, url, headers and body and nothing else, so the program’s own redirect option never crosses to your function. Programs asking for redirect: 'manual' or redirect: 'error' get whatever you decided. Chase the chain yourself, and where you need the guest’s intent, take it from a header of your own.

Whatever your function throws arrives inside the program as a TypeError with your message on it. So a refusal reads to the model the way a network failure does, and it names the URL that was turned down:

{
result: {
name: 'TypeError',
message: 'this run may not reach https://evil.example/x'
},
logs: []
}

Your function is the only allow list in the path, so keep the list here. Keep the credential here as well, because the header your function adds is attached outside the isolate and the program cannot read it back off a request it did not make:

await executor.execute({
code: `async () => {
const request = new Request('http://127.0.0.1:8080/items')
const answer = await fetch(request)
return { onTheRequest: request.headers.get('authorization'), body: await answer.text() }
}`,
outbound: forward,
})
// { result: { onTheRequest: null, body: 'ordinary answer' }, logs: [] }

onTheRequest reads null while the server behind forward sees the Bearer header. Every exchange passes through this one function as plain data, so a host that wants a record of what a program asked for writes it here. 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, so your policy and your credential stay on your machine while the program runs on someone else’s.

Whatever your function returns becomes a real Response inside the isolate, with the status, the status text, the headers and the body you gave it. But the last three rows part 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, whatever the URL was
answer.redirected Always false, even after a chain your function followed
answer.body The whole body in one chunk, never split

Those three follow from the host performing the exchange, since the isolate never sees a redirect chain or a partial body. The sandbox sets no Origin header on a request the program builds, and a Headers object inside it keeps the names a browser forbids. So Host and Connection arrive at your function as the program wrote them. Each of these is stated with its reason in Runtime deviations. Sanitize what you forward.

Start from null, and open the network only for what a tool of yours cannot do:

outbound Choose it when What the program reaches
null Whatever the program needs can arrive through a tool you wrote Nothing at all: fetch rejects with the declared text
A function You hold an allow list, a credential the program must never see, or an exchange to record Whatever your function returns, at a URL your function approved
Absent The executor already sits inside a network its operator has closed Whatever that network reaches, at a URL the program composed

But native mode follows a redirect chain wherever it leads. The Response it hands back 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: []
}

url names the URL the program asked for and body is the second server’s. Operators can veto that state. A daemon that denies native mode denies it however a caller asked for it, and the program meets the rejection null gives. The operator guide has that setting, and a caller that supplied its own function, or that passed null, is untouched either way. Leaning on the surrounding network has costs of its own, and the threat model states them.

The run clock separates the two open states. A wait inside your function 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.

A program turned down by outbound has no second surface to try. WebSocket, XMLHttpRequest, EventSource and navigator.sendBeacon are outside 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: [] }

The resolver has one place to look, so esm.sh and every other module CDN resolve the same way. A corpus of programs written to find another way out attacks these surfaces on every run of pnpm verify, under the method of the benchmark. A request has a size bound as well, stated in the bounds guide, and it applies in both states where a request leaves the isolate. Under null nothing leaves, so the refusal text comes back in its place. For how the setting crosses the wire, and why the state travels as an explicit mode string, read the sandbox chapter.

outbound decides where a program may go. Bounds says how long it may run and how much memory it may use, and Modules hands it code to import in place of code to fetch.