---
title: "Network permissions"
description: "Declare the hosts your app may call; every other outbound call is blocked and reported, in CI and in production."
section: "interfaces"
status: "Building for 0.3 · ships as a beta API"
spec: "network"
canonical: "https://mainsdk.dev/docs/interfaces/network"
updated: "2026-10-28"
---

# Network permissions

> Declare the hosts your app may call; every other outbound call is blocked and reported, in CI and in production.

Status: Building for 0.3 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/network · Updated: 2026-10-28

`network` makes your app declare who it may talk to, like a mobile app's permissions. Anything not on your list in `main.yaml`, or declared by a driver, is blocked and reported.

## At a glance

- Status: Building for 0.3 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/network.md
- Category: Platform, OS and network

Calls:

- `http.fetch(url, init)`
- `http.get(url, init)`
- `http.post(url, body, init)`
- `http.json(url, init)`
- `http.events(url, init)`
- `http.websocket(url, protocols)`
- `http.assert(url)`
- `network.destinations()`
- `exportAwsSg(allow, opts)`
- `exportK8s(allow, opts)`
- `isPrivateIp(ip)`
- `parseRule(text)`
- `createHttp(net)`
- `network.assert(target)`
- `network.mode`
- `network.rules`
- `network.decisions`
- `network.entries`
- `network.breakerSlots()`
- `network.breakerStates()`
- `network.decide(url, reason?, caller?)`
- `network.decidePublic(url, caller?)`
- `network.fetch(input, init?)`
- `network.fetchPublic(input, init?)`
- `network.matchHost(host)`
- `network.matchIp(ip)`
- `network.reload(opts)`
- `network.settingsFor(host, call?)`
- `network.trim()`

In main.yaml:

```yaml
network:
  allow:
    - api.partner.com
    - "*.pinecart.dev"
  default: deny
  mode: enforce
  http:
    timeout: 10s
    connect: 3s
    retries: { attempts: 3, backoff: 200ms, max: 5s, deadline: 30s }
    breaker: { failures: 50%, window: 30s, min: 10, cooldown: 15s }
    hosts:
      api.partner.com: { timeout: 3s, retries: { attempts: 5 } }
exceptions:
  - { rule: net.default_deny, reason: "partner VPN", owner: "@pinecart/platform", expires: 2026-11-30 }
```

Guarantees, whatever the driver:

- Deny by default; undeclared hosts blocked and reported
- DNS rebinding and redirects to undeclared hosts refused
- Every helper (get, post, json, events, websocket) passes the same check as http.fetch
- http.json and http.events refuse non-2xx answers and non-JSON bodies with MAIN-NET-0008
- Every outbound call has a timeout; retries and a per-host breaker are built in, with no hand-rolled loops
- Free on every plan


## Drivers

Pick one in main.yaml with `network.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in | `builtin` | yes |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-NET-0001` | check | Raw socket, another HTTP client or direct `fetch` outside the SDK: `new WebSocket`, `EventSource`, `node:http`, `node:https`, `node:dgram`, `ws`, or `git`, `curl` or `wget` run as a child process |
| `MAIN-NET-0004` | check | A third-party API offers less than TLS 1.2 (TLS 1.3 is the minimum for endpoints your app serves, under `MAIN-NET-*`) |
| `MAIN-NET-0007` | check | The code connects to a host that `network.allow` doesn't list; the finding names the call (`http`, `websocket`, `event-stream`, `database`, `git`) |
| `MAIN-NET-0040` | check | The code calls a non-local host over plain `http://` |
| `MAIN-NET-0041` | check | TLS below 1.2 is forced, or certificate checks are turned off |
| `MAIN-NET-0050` | check | `Info.plist` or `app.json` turns off App Transport Security with `NSAllowsArbitraryLoads`, so the app can load plain http |
| `MAIN-NET-0051` | check | `AndroidManifest.xml` or `app.json` allows cleartext traffic (`usesCleartextTraffic`), so the app can send data over plain http |
| `MAIN-NET-0002` | runtime | Call to a host not in `network.allow` (or not declared by a driver) |
| `MAIN-NET-0003` | runtime | Redirect to an undeclared host refused |
| `MAIN-NET-0005` | runtime | An entry in `network.allow` is not an IP, a CIDR range, an exact host or a `*.domain` (for example a prefix length out of range) |
| `MAIN-NET-0006` | runtime | A request was redirected more times than the SDK follows, or `redirect: "error"` met a redirect |
| `MAIN-NET-0008` | runtime | `http.json` or `http.events` got a non-2xx answer (429 when the provider rate-limits, else 502), or JSON that didn't parse; the body is never echoed |
| `MAIN-NET-0009` | runtime | An outbound call timed out: the connect or total timeout of its last attempt, or its deadline, passed (504; the error names which) |
| `MAIN-NET-0010` | runtime | The circuit breaker for the host is open, or half-open with its probe in flight; no request was sent (503, `retryable`, `retryAfter` is the rest of the cooldown) |
| `MAIN-NET-0011` | runtime | An outbound call failed with a network error (refused, reset, DNS failure) on its last attempt (502; states the number of attempts) |
| `MAIN-NET-0012` | runtime | A value under `network.http` is invalid: a bad duration, `attempts` outside 1 to 10, `failures` outside 1 to 100 %, `min` below 1, or an unknown key (fails at load; names the key) |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Allowlist in main.yaml with driver-declared hosts | 1 | 0.3 | `network#schema` |
| Enforcement in the SDK network layer | 1 | 0.3 | `network#chokepoint` |
| DNS and redirect checks | 1 | 0.3 | `network#dns-redirects` |
| Blocked calls reported | 1 | 0.3 | `network#report` |
| Check against raw sockets and other HTTP clients | 1 | 0.3 | `network#build-check` |
| Export as platform firewall rules | 1 | 0.3 | `network#export` |
| Helpers for GET, POST, JSON, server-sent events and WebSockets | 1 | 0.3 | `network#helpers` |
| http.assert for database URLs and git remotes before connecting | 1 | 0.3 | `network#helpers` |
| Discovery of every host the code uses (mainsdk discover) | 1 | 0.3 | `network#discover` |
| Discovered hosts written as a build-time manifest (discover --out) | 1 | 0.3 | `network#discover` |
| Timeouts per attempt and per call, with a connect timeout | 1 | 0.3 | `network#timeouts` |
| network.http settings per host, with field-by-field overrides | 1 | 0.3 | `network#http-config` |
| Retries for idempotent calls, with backoff and Retry-After | 1 | 0.3 | `network#retries` |
| Per-host circuit breaker | 1 | 0.3 | `network#breaker` |
| Every attempt and breaker change in the decision log | 1 | 0.3 | `network#attempt-log` |
| Driver defaults for timeouts and idempotency keys | 1 | 0.3 | `network#driver-defaults` |
| Measured overhead against the performance budget | 1 | 0.3 | `network#reliability-overhead` |

## MainSDK Cloud

Everything on this page is free in your app.
Cloud adds:

| Plan | What it adds |
| --- | --- |
| fleet | Egress allowlists, approved providers and models enforced across every app |


## Quick start

```yaml title="main.yaml"
network:
  allow:
    - api.partner.com
  default: deny
  mode: enforce
```

TypeScript:

```ts
import { http } from "mainsdk";

export async function loadRates() {
  // allowed: api.partner.com is in network.allow
  const res = await http.fetch("https://api.partner.com/v1/rates");
  return res.json();
}
```

Rust:

```rust
use mainsdk::prelude::*;
let app = App::test();
let err = app.network().assert("https://example.com/x").unwrap_err();
assert_eq!(err.code, ErrorCode::MainNet0002);
```


A call to a host that is not on the list, and not declared by a driver you use, is blocked with `MAIN-NET-0002`.

## Configuration

Every key below lives under `network:`.

```yaml title="main.yaml"
network:
  allow:
    - 8.8.8.8            # one IP
    - 10.20.0.0/16       # a range (IPv4 or IPv6 CIDR)
    - google.com         # exactly this domain, not its subdomains
    - "*.pinecart.dev"   # subdomains of pinecart.dev only, not pinecart.dev itself
  default: deny          # everything else is blocked and reported
  mode: enforce          # observe | warn | enforce
```

| Key | Type | What it does |
| --- | --- | --- |
| `allow` | list | Your own destinations: IPs, CIDR ranges, exact domains, or `*.domain` wildcards |
| `default` | string | What happens to anything not on the effective list. The default is `deny` |
| `mode` | string | `observe` logs a would-block and lets the call through, `warn` allows and reports, `enforce` blocks |

The effective list is your `allow` list plus the hosts of the drivers in use. The Resend driver declares `api.resend.com`, so you never list provider hosts yourself.

Matching rules:

- An exact domain does not match its subdomains. `google.com` does not allow `mail.google.com`.
- `*.domain` matches subdomains only. `*.pinecart.dev` allows `api.pinecart.dev` but not `pinecart.dev`.
- Prefer domains; IPs behind CDNs change. IPs suit fixed services such as a partner's VPN gateway.
- A plain entry covers HTTP, HTTPS and WebSocket only. Raw TCP and UDP need an entry with the protocol and port, such as `tcp://db.acme.dev:5432` or `udp://8.8.8.8:53`, and listening needs `network.listen`. See [TCP and UDP sockets](https://mainsdk.dev/docs/interfaces/net).

### Inherited and overlay lists

`main.org.yaml` can set the list for every app. An app can remove hosts (tightening), but adding a host the org does not allow is weakening: it fails with `MAIN-CONFIG-0010` unless an exception names the rule, a reason, an owner and an expiry:

```yaml title="main.yaml"
exceptions:
  - { rule: net.default_deny, reason: "partner VPN", owner: "@pinecart/platform", expires: 2026-11-30 }
```

An expired exception fails like no exception. See [main.yaml](https://mainsdk.dev/docs/concepts/main-yaml).

## Helpers

`http.fetch` is the base. The helpers below cover the calls apps make most often. Each one goes through the same allow-list check, and the decision names the calling file and line.

| Call | Use it for | Returns |
| --- | --- | --- |
| `http.fetch(url, init)` | Any request, when you want to read the response yourself | `Response` |
| `http.get(url)` | A plain GET | `Response` |
| `http.post(url, body)` | A POST; an object body is sent as JSON | `Response` |
| `http.json(url, { body, schema })` | A request whose answer is JSON. Sends `body` as JSON when it is given, and checks the answer against `schema` when you pass one (`MAIN-SCHEMA-0001` on a mismatch). Throws `MAIN-NET-0008` on a non-2xx answer or a body that is not JSON | the parsed value |
| `http.events(url)` | Server-sent events, one event per iteration | an async iterator of `{ event?, data }` |
| `http.websocket(url, protocols?)` | A WebSocket. The host is checked before the connection opens | `WebSocket` |
| `http.assert(url)` | A database URL or git remote that the SDK does not carry for you. The host is checked before you connect | the decision |

```ts title="lib/partner.ts"
import { http } from "mainsdk";

// allowed: api.partner.com is in network.allow
export async function quote(sku: string) {
  return http.json<{ price: number }>("https://api.partner.com/v1/quotes", { body: { sku } });
}

export async function watchPrices() {
  for await (const event of http.events("https://api.partner.com/v1/prices/stream")) {
    console.log(event.data);
  }
}

export async function openFeed() {
  return http.websocket("wss://feed.partner.com/live");
}
```

Use `http.assert` before a driver or library opens a connection the SDK cannot wrap, such as a database client. It throws `MAIN-NET-0002` for a host that is not allowed, and returns the decision when it is:

```ts title="lib/db.ts"
import { http } from "mainsdk";

const url = process.env.DATABASE_URL ?? "";
await http.assert(url);   // throws MAIN-NET-0002 if the database host is not allowed
```

Server-side programs such as `git` or `curl` run through `system.run` (see [Server facts and programs](https://mainsdk.dev/docs/interfaces/system)). Every URL in their arguments is checked against this list too.

## Reliable calls

Every outbound call is bounded and retried when it is safe, and a host that keeps failing is cut off. You write `http.get()` and get the same behaviour as every driver, with no timeout or retry loop of your own.

```yaml title="main.yaml"
network:
  http:
    timeout: 10s           # one attempt, until the body is read; "off" for none
    connect: 3s            # one attempt, until the response headers arrive
    retries: { attempts: 3, backoff: 200ms, max: 5s, deadline: 30s }   # or retries: off
    breaker: { failures: 50%, window: 30s, min: 10, cooldown: 15s }   # or breaker: off
    hosts:                 # an exact host or "*.domain", as in network.allow
      api.partner.com: { timeout: 3s, retries: { attempts: 5 } }
```

These are the defaults, except `hosts`. Settings resolve per field, most specific first: the call, then the host's entry in `hosts`, then the driver's defaults for that host, then `network.http`, then the built-in defaults.

### Options on each call

| Option | What it does |
| --- | --- |
| `timeout` | Total time for one attempt, from the request until the body is read. `"off"` disables it |
| `connect` | Time for one attempt until the response headers arrive |
| `retries` | `false`, a number of attempts, or `{ attempts, backoff, max, deadline }` for this call |
| `idempotencyKey` | Sent as `Idempotency-Key` on every attempt. It makes a `POST` retryable |

```ts title="lib/partner.ts"
import { http } from "mainsdk";

// a slow report: give it more time, and do not retry it
export function report(id: string) {
  return http.get(`https://api.partner.com/v1/reports/${id}`, { timeout: "30s", retries: false });
}

// a charge: the key makes the POST safe to retry
export function charge(orderId: string, body: { amount: number }) {
  return http.post("https://api.partner.com/v1/charges", body, { idempotencyKey: orderId });
}
```

Use one key per logical call and reuse it on every retry. A new key for a retry defeats the point.

### What is retried

- Only `GET`, `HEAD`, `PUT`, `DELETE` and `OPTIONS` are retried, and a `POST` that carries an `idempotencyKey`. A request whose body is a stream is never retried.
- A retry happens after a network error, a timed-out attempt, or a `408`, `429`, `502`, `503` or `504` answer. Any other answer is returned as it is.
- Waits use full jitter: a random wait up to `backoff` doubled each attempt, capped at `max`. A `Retry-After` header on a `429` or `503` replaces that wait, even above `max`.
- A call stops after `attempts` tries or when the next wait would pass `deadline`. It then returns the last answer, or fails with `MAIN-NET-0009` (timed out) or `MAIN-NET-0011` (network error).
- A call blocked by the allowlist (`MAIN-NET-0002`) is never retried and never counts against the breaker.

### The circuit breaker

Each host has its own breaker in each app instance. When the share of failed attempts in the last `window` reaches `failures`, with at least `min` attempts in it, the breaker opens. A failure is a network error, a timeout, or a `500`, `502`, `503` or `504`; a `429` or other `4xx` is not.

While the breaker is open, a call to that host fails at once with `MAIN-NET-0010` and sends nothing. After `cooldown` one probe call goes through; if it succeeds the breaker closes, and if it fails the breaker opens again. Calls to other hosts are unaffected. A breaker's state is per instance and starts closed on every start.

Every attempt and every breaker change is written to the decision log, with the attempt number, its wait and its outcome. Bodies, `Authorization` headers and `Idempotency-Key` values are never logged.

## Find what your code calls

```bash
npx mainsdk discover
```

`discover` reads your source and `main.yaml` and lists every host the code connects to, with the file and line, the kind of connection and whether `main.yaml` declares it. It also lists the server folders, facts and programs the app uses, which are covered by [Declare what your app uses](https://mainsdk.dev/docs/guides/declare-what-your-app-uses). Each undeclared host is marked `MISSING`, and the output ends with the `network.allow` lines to add.

```text
network
  declared http         https://api.partner.com  lib/partner.ts:5
  MISSING  websocket    wss://feed.partner.com  lib/partner.ts:15
```

Three more ways to use it:

- `npx mainsdk discover --json` prints the full manifest for scripts and agents.
- `npx mainsdk discover --out build/manifest.json` writes the manifest to a file at build time, so a review or a platform rule can read what the app uses without running it.
- `npx mainsdk network export --to k8s --discovered` adds the hosts the code uses that `network.allow` doesn't list yet.

`mainsdk check` fails with `MAIN-NET-0007` for any host the code uses that `network.allow` doesn't declare. A URL that is only known when the app runs, such as one read from a variable, is listed as dynamic: discovery can't read its host, so the runtime check is what guards it.

## Examples

### Call a partner API from a route handler

```ts title="app/api/quotes/route.ts"
import { auth, http } from "mainsdk";

export async function POST(req: Request) {
  await auth.require(req);
  const body = (await req.json()) as { sku: string; qty: number };

  const res = await http.fetch("https://api.partner.com/v1/quotes", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ sku: body.sku, qty: body.qty }),
  });
  return Response.json(await res.json(), { status: res.status });
}
```

`http.fetch(url, init)` is the client every driver and app call uses, so you write it like `fetch`.

### Roll a new list out safely

Adopting `network` on an existing app: start in `observe`, read what would be blocked, add what is legitimate, then enforce.

```yaml title="main.env.prod.yaml"
network:
  mode: observe     # calls succeed; would-block decisions are logged
```

Every block or would-block is written to the decision log with the destination, the rule and the calling file and line, and shows in `mainsdk info`:

```bash
npx mainsdk info --json
```

Once the log is clean, switch the overlay to `warn`, then `enforce`.

### Internal services from an Express worker

Private ranges are reachable only when you declare them. A DNS answer that points an allowed name at a private address you did not declare is refused as rebinding.

```yaml title="main.yaml"
network:
  allow:
    - "*.pinecart.dev"
    - 10.20.0.0/16        # the internal billing service
  default: deny
```

```ts title="worker/billing.ts"
import { createServer } from "node:http";
import { http } from "mainsdk";

createServer(async (req, res) => {
  const id = req.url?.split("/")[2] ?? "";   // /invoices/:id/sync
  const upstream = await http.fetch(`http://10.20.4.12/invoices/${id}`);   // inside 10.20.0.0/16
  res.writeHead(upstream.status, { "content-type": "application/json" });
  res.end(JSON.stringify(await upstream.json()));
}).listen(3000);
```

### A second wall at the platform

Export the same list as platform egress rules, so the network blocks undeclared hosts even if code finds a way around the SDK.

```bash
npx mainsdk network export --to k8s
```

Targets are `k8s` (a NetworkPolicy) and `aws-sg` (security group egress rules). Exports for Vercel and Cloudflare are planned. Platform rules match IPs, so names are resolved when you export. For Kubernetes the policy holds your CIDR egress rules, such as `10.20.0.0/16` and `8.8.8.8/32`. `--discovered` adds hosts the code uses that `network.allow` does not list yet.

## Reference: the network layer

The `Network` instance behind `http` has calls of its own. They are for adapters, drivers, tests and tooling. Most apps use only the `http` helpers above.

| Call | What it does |
| --- | --- |
| `network.fetch(input, init?)` | Fetch through the allow list with timeouts, retries and the host's circuit breaker. `http.fetch` calls it. |
| `network.assert(target)` | Check a destination the SDK doesn't carry itself, such as a database URL, a WebSocket or a git remote. It throws `MAIN-NET-0002` when enforce mode blocks it. `http.assert` calls it. |
| `network.decide(url, reason?, caller?)` | Decide one `URL`: the host must be declared, and DNS answers must not point at an undeclared private range. Returns the decision, with the calling file and line. |
| `network.matchHost(host)` | The allow rule that covers a host name, or `null`. |
| `network.matchIp(ip)` | The CIDR rule that covers an address, or `null`. |
| `network.settingsFor(host, call?)` | The timeouts, retry and breaker settings a call to this host resolves to, with the call's own options on top. For tests and `mainsdk info`. |
| `network.reload(opts)` | Replace the allow list, mode and default while the app runs. Driver hosts stay allowed. `opts` takes `allow`, `default` and `mode`. |
| `network.breakerStates()` | The circuit breaker state of each host, as `{ host, state }`. `health()` reports them as remote dependencies. |
| `network.breakerSlots()` | How many numbers the breakers hold in all. It grows with hosts, never with traffic. |
| `network.trim()` | Drop the older half of the decision log. A pressure hook calls it under memory pressure. |
| `network.mode` | The mode in force: `observe`, `warn` or `enforce`. `network.reload()` changes it. |
| `network.rules` | The parsed allow rules, in the order `network.allow` lists them. |
| `network.decisions` | The newest 1,000 decisions, each with the host, `allow` or `deny`, the rule that matched and the calling file and line. |
| `network.entries` | Decisions, attempts and breaker changes in order, the newest 1,000. |

### Customer-chosen URLs

A URL a customer typed, such as a webhook endpoint, must never reach a private address, and no `network.allow` entry can change that. These two calls take no allow-list entry.

| Call | What it does |
| --- | --- |
| `network.decidePublic(url, caller?)` | Check that a `URL` is `https`, carries no credentials, and that its host and every address it resolves to now are public. Throws `MAIN-HOOK-0004` without connecting. |
| `network.fetchPublic(input, init?)` | Send one request to such a URL. The address is checked again at every connect, no redirect is followed and nothing is retried. The answer comes back whatever its status. |

### Helpers

These functions are exported for tooling and tests.

| Function | What it does |
| --- | --- |
| `parseRule(text)` | Parse one allow entry: an IP, a CIDR range, an exact host or `*.domain`. Throws `MAIN-NET-0005` for anything else. |
| `isPrivateIp(ip)` | `true` for loopback, private, carrier-grade NAT and link-local addresses. |
| `createHttp(net)` | Build the `http` client on one network layer, given a function that returns it. Tests use it to run `http` on a separate layer. |
| `exportK8s(allow, opts)` | Write the allow list as a Kubernetes NetworkPolicy. Names are resolved to `/32` or `/128` addresses first. |
| `exportAwsSg(allow, opts)` | Write the allow list as AWS security group rules (`IpPermissions` JSON). |

Both exports take `{ name?, resolve?, listen? }`: a name for the policy, a function that resolves a host to addresses, and the `network.listen` entries from `main.yaml`. They are what `npx mainsdk network export` runs.

## What you get

- Every outbound call goes through the SDK's network layer and is checked against the list in `main.yaml`, plus the hosts your drivers declare.
- A poisoned dependency that tries to send your secrets to a new domain fails in CI and is stopped in production.
- Reach for `http.fetch` whenever your own code calls an outside API.
- It runs inside your app and is free on every plan.

## Errors and checks

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-NET-0001` | `mainsdk check` found a raw socket, another HTTP client (for example `node:net`, `axios`), a direct `fetch`, `new WebSocket` or `EventSource`, or `git`, `curl` or `wget` run as a child process outside the SDK | Use an `http` helper, or move the call into a driver; run programs with `system.run` |
| `MAIN-NET-0002` | A call went to a host that is not on the effective list | Add the host to `network.allow` if it is legitimate; otherwise find the code that called it |
| `MAIN-NET-0003` | An allowed host redirected to an undeclared host | Declare the redirect target, or call the final URL directly |
| `MAIN-NET-0004` | A Main 27 network rule, for example a third-party API that offers only TLS 1.1 | Use an endpoint that offers TLS 1.2 or later, or record an exception with an owner and an expiry |
| `MAIN-NET-0005` | An entry in `network.allow` is not an IP, a CIDR range, an exact host or a `*.domain` | Fix the entry; the error names it |
| `MAIN-NET-0006` | A request was redirected more times than the SDK follows, or a `redirect: "error"` request met a redirect | Call the final URL directly, or fix the redirect loop |
| `MAIN-NET-0007` | `mainsdk check` found a host the code uses that `network.allow` doesn't list | Add the host, or remove the call; `npx mainsdk discover` prints the lines |
| `MAIN-NET-0008` | `http.json` or `http.events` got a non-2xx answer (429 when the provider rate-limits, otherwise 502) or a body that is not JSON. The body is never echoed | Check the URL, the credentials and the service status, or read the response with `http.fetch` |
| `MAIN-NET-0009` | An outbound call timed out: the connect or total timeout of its last attempt, or its deadline, passed (504; the error names which) | Raise the timeout for that host or call, or check the provider's latency |
| `MAIN-NET-0010` | The circuit breaker for the host is open, or half-open with its probe in flight. No request was sent (503, `retryable`, `retryAfter` is the rest of the cooldown) | Wait for `retryAfter`, and check the provider's status |
| `MAIN-NET-0011` | An outbound call failed with a network error (refused, reset, DNS failure) on its last attempt (502; states the number of attempts) | Check the host, DNS and the provider's status |
| `MAIN-NET-0012` | A value under `network.http` is invalid: a bad duration, `attempts` outside 1 to 10, `failures` outside 1 to 100 %, `min` below 1, or an unknown key. It fails at load and names the key | Fix the value the error names |

The decision log names the destination, the rule and the calling file and line for every block. The check codes for this interface are listed above.

## Swapping drivers

`network` has one driver, built in. Other interfaces' drivers feed it: each driver declares the hosts it calls, and those join the effective list. When you swap `email` from Resend to SES, `api.resend.com` leaves the list and the SES host joins it, with no change to `network.allow`. `network.destinations()` shows the result.

Driver calls go through the same layer. A driver that reaches a host it did not declare is blocked with `MAIN-NET-0002` like any other call. A kit for writing your own drivers (`defineDriver`, spec `cohesion`) is planned.

## Testing

Route tests run on the in-memory fakes, so they make no real network calls. Assert the guarantee itself with a test named by its criterion:

```ts title="lib/rates.test.ts"
import assert from "node:assert/strict";
import { it } from "node:test";
import { createTestApp } from "@mainsdk/testing";
import { http } from "mainsdk";

it("network AC1 blocks an undeclared host", async () => {
  const app = createTestApp({
    yaml: "schema: 1\nproject: rates\nnetwork:\n  allow: [api.partner.com]\n",
    respond: () => Response.json({ price: 5 }),   // what an allowed host answers
  });
  await assert.rejects(http.fetch("https://evil.example"), { code: "MAIN-NET-0002" });
  assert.deepEqual(await http.json("https://api.partner.com/v1/rates"), { price: 5 });
  assert.equal(app.sent.length, 1);   // only the allowed call was sent
});
```

`respond` answers every call the list lets through; without it the fake answers `204`. `dns` maps names to fake addresses for the rebinding check. See the [Testing kit](https://mainsdk.dev/docs/interfaces/testing).

## Security and privacy

- Deny by default. Undeclared hosts are blocked and reported.
- The decision runs in process, with no network round trip.
- DNS answers are checked against the list, so a name cannot be rebound to a private range you did not declare. Redirects to undeclared hosts are refused.
- Raw sockets and other HTTP clients fail the check at build time.
- Free on every plan: an app protecting itself is never a paid action.

### What Cloud adds

Your allowlist and its enforcement are free in your app. On Fleet, a security team enforces egress allowlists, approved providers and approved models across every app in the estate. Fleet can tighten your list, never loosen it. See [free in-app and Cloud](https://mainsdk.dev/docs/concepts/free-and-cloud).

## FAQ

**Do I list Stripe, Resend and my database host?** No. Drivers declare their own hosts. You list only your own destinations.

**Does `*.pinecart.dev` allow `pinecart.dev`?** No. Wildcards match subdomains only. List `pinecart.dev` separately if you call it.

**Does this cover inbound traffic or CORS?** No. `network` is outbound only. Secure headers, CSRF and CORS are [inbound protection](https://mainsdk.dev/docs/guides/sanitize-and-protect-inbound); rate limits are in [rate limits and quotas](https://mainsdk.dev/docs/interfaces/limits).

**Can I try it without breaking production?** Yes. Set `mode: observe` and read the would-block decisions first.

## List the destinations

`network.destinations()` returns every destination the app may reach: your `allow` list plus the hosts your drivers declare. It is what `mainsdk info` prints.

```ts title="lib/egress.ts"
import { network } from "mainsdk";

const hosts = network.destinations();   // ["api.partner.com", "api.resend.com", ...]
```

## Next

- [Declare what your app uses](https://mainsdk.dev/docs/guides/declare-what-your-app-uses): hosts, folders, facts and programs in one place.
- [Local files](https://mainsdk.dev/docs/interfaces/disk) and [Server facts and programs](https://mainsdk.dev/docs/interfaces/system): the server's own disk and programs, under the same permission model.
- [Policy decisions](https://mainsdk.dev/docs/interfaces/policy): the network list compiles into policy you can explain.
- [Secrets](https://mainsdk.dev/docs/interfaces/secrets): keep the keys a poisoned dependency would try to send out.
- [The check](https://mainsdk.dev/docs/concepts/the-check): what `mainsdk check` runs before merge.
