Guides / Network rules
Network rules
A sandbox has its own private network, and you decide what it can reach beyond it: everything, nothing, or a list. The decision is enforced on your computer, in a place nothing inside the sandbox can touch. This page covers the rules, where they are checked, what a refusal looks like, and the stricter layer the egress broker adds on top.
The three policies
Every sandbox has one network policy, fixed when it is created. kosa8 calls it the egress policy: egress is traffic leaving the sandbox.
kosa8 sandbox create if you say nothing; see
defaults.$ kosa8 sandbox create dev --egress-allow registry.npmjs.org sandbox dev ready in 737ms (32 GB disk) $ kosa8 sandbox egress dev policy: allow:registry.npmjs.org (enforced on the host) frames: 3 passed, 0 denied, 1 dropped as link noise dns: 2 answered, 0 refused
What can go in a list
| Entry | Example | What it admits |
|---|---|---|
| An exact name | registry.npmjs.org | That name and no other.
Allowing example.com does not allow www.example.com. |
| A wildcard | *.example.com | Every name below
example.com, at any depth: a.example.com and
a.b.example.com. Not example.com itself; list that too if you need
it. |
| An IPv4 address | 203.0.113.10 | That address. |
| An IPv4 network | 10.20.0.0/16 | Every address in it, written as a CIDR range. |
Names are compared in lower-case with any trailing dot removed. A wildcard matches on the dot,
so *.example.com does not admit evil-example.com or
example.com.evil.net.
What is refused when you write the rule
A typo in a security setting is better reported than guessed at, so kosa8 refuses any policy it does not fully understand instead of treating it as something close.
| You wrote | kosa8 says |
|---|---|
* | a bare * would allow every name; use --egress any, or list *.domain entries |
*.com | com is a public suffix, under which anyone can
register a name, so the wildcard would admit strangers' hosts; allow a domain below it, as in
*.example.com |
api.*.com, *api.com | a wildcard must be the whole first label, as in
*.example.com; a * anywhere else is not supported |
*.10.0.0.1 | a wildcard over an address matches nothing; list the address or a CIDR |
| An IPv6 address or network | only IPv4 is routed from a sandbox; list an IPv4 address or a hostname |
| An empty list | allows nothing; use deny, or list the hosts to allow |
| Anything else | want any, deny, fakenet, or allow:HOST[,HOST...] |
Every single-label wildcard (*.com, *.io) is refused that way, along
with a built-in list of country second levels and hosting platforms where strangers can publish, such as
co.uk, github.io, vercel.app and
amazonaws.com, so *.co.uk and *.github.io get the same
answer. The list is a guard against the obvious mistake. It is not the full
Public Suffix List, so a wildcard over a suffix it does not know is accepted.
Where you set it
| Where | How |
|---|---|
kosa8 sandbox create | --egress any or
--egress deny; --egress-allow HOST, repeatable, for a list. A list
implies deny for everything else. --egress any together with
--egress-allow is refused as a contradiction. |
kosa8 mcp add | The same two flags, for the sandbox a third-party tool
server runs in: --egress any or deny, and
--egress-allow for hosts or *.domain wildcards. |
| A kit file | egress: deny, or egress_allow: with a list.
kosa8 agent run --no-network overrides the kit with deny. |
| An AI agent, over MCP | The egress and egress_allow
arguments of the sandbox_create tool. |
| Your organisation | default_egress in the signed policy, applied to any
sandbox created without a rule of its own. |
kosa8 sandbox create accepts a fourth value, --egress fakenet, for
malware analysis: nothing leaves, and a simulated internet on your computer answers and records
everything. It is not covered here; see kosa8 lab --help. The full flag list is on the
sandbox create and
mcp add reference pages.
Where it is enforced
The policy is applied twice, in two different places, and only one of them is the boundary.
- A program in the sandbox opens a connection.
- A firewall inside the virtual machine applies the policy to container traffic first. It is inside, so whoever is root inside can remove it.
- The packet then leaves the machine, and the only way out is through the guard, a part of the
kosa8dservice on your computer. - Traffic to a destination the policy admits is passed on unchanged.
- Anything else is dropped, answered so the program fails at once, counted, and written to the audit log.
The guard is the boundary because of where it sits. Every frame the virtual machine sends is delivered to a socket that the service reads, and it is read there before anything decides to forward it. Root inside the sandbox can rewrite the machine's own firewall, routes and DNS settings. It cannot reach a process on your computer.
The inner firewall stays for two reasons: it refuses container traffic before it leaves the
machine at all, and it fails closed if the outer layer were ever misconfigured. One exception: a
policy with a *.domain entry is enforced by the guard alone, because the inner layer
cannot resolve a wildcard ahead of time and steps aside.
The guard looks at frames, not connections, so its check covers things a connection-level filter never sees. IPv6, multicast and other link-level chatter are dropped and counted as "link noise", not logged one by one: a Linux machine sends some at every boot.
How names are resolved
A list of names is only sound if the sandbox cannot decide what those names mean. So the sandbox does not get to ask a DNS server of its choosing.
- A program asks for a name. It can address the question to any server; it makes no difference.
- Every DNS query is taken by the guard and answered there. None is forwarded.
- For a listed name, or one under a wildcard, your computer's own resolver does the lookup.
- The answer goes back, and those addresses, and only those, become reachable.
- A name that is not on the list gets
REFUSED, and the attempt is recorded with the name.
Two consequences are worth knowing. A sandbox cannot "resolve" an allowed name through a server it controls and have that address admitted, because the only answers that count are the ones your computer obtained. And no bytes the sandbox chose reach a resolver outside your computer, except as the name being looked up.
- Under
deny, every lookup is refused. - Answers are IPv4 only, up to 16 addresses, with a lifetime of 60 seconds so the sandbox asks again often. An address it was told stays reachable for 24 hours.
- For an allowed name, a question kosa8 does not forward (an IPv6 address, a mail record) gets an empty answer, so the program moves on at once.
- Entries that are addresses or networks need no lookup; they are reachable from the start.
Names cannot point inward
An allowed name is a statement about the public internet. Whoever controls that name's DNS
decides what it resolves to, and could point it at 127.0.0.1, at the cloud metadata
address 169.254.169.254, or at a machine on your own network. That would turn "may
reach api.example.com" into a route to your computer. This trick is called DNS rebinding.
So an answer is only handed to the sandbox, and only admitted, if it is a public address.
Private, loopback, link-local, carrier-grade NAT and reserved addresses are withheld, and the
attempt is logged as allowed name resolved to a private, loopback or link-local
address. If you do mean a private host, list its address or network as a literal entry.
That is you saying so on purpose, and it is honoured.
What a refusal looks like
A refused connection fails immediately. The guard answers on behalf of the destination, so a program gets an error instead of waiting out a timeout.
| The sandbox sends | It gets back | A program sees |
|---|---|---|
| A new TCP connection | A TCP reset (RST) | Connection refused |
| A UDP datagram | ICMP "administratively prohibited" | An unreachable error |
| A DNS question | A REFUSED answer | server can't find example.com: REFUSED |
| IPv6, multicast, other link traffic | Nothing | No answer. Dropped and counted as link noise, not logged |
From your side, kosa8 sandbox egress shows the policy in force and the counts
since the sandbox booted: frames passed, denied and dropped, and lookups answered and refused. Its
output is at the top of this page. For a policy with wildcards it adds a wildcard: line for each entry, listing the
names the sandbox was answered for under it: the first 64, then a count. That is how you find out
what a wildcard is really being used for. For an open sandbox it says so:
nothing is refused; create with --egress deny or --egress-allow to restrict.
Each refused attempt is also an entry in the audit log, with the action
sandbox.egress.deny. These two are from a real sandbox whose policy was
deny: a lookup, and a connection to a port on the host.
sandbox.egress.deny {"proto":"dns","dst":"8.8.8.8","port":53,"name":"example.com","reason":"name not in egress policy deny"} sandbox.egress.deny {"proto":"tcp","dst":"192.168.127.254","port":22,"reason":"not in egress policy deny"}
A sandbox can be refused far faster than a tamper-evident log should grow, so at most 20
refusals a minute are written for each sandbox. The rest are counted, and the count is carried on
the next entry as suppressed. The totals in kosa8 sandbox egress are
exact. See The audit log.
The egress broker, on top
Everything so far is a rule about addresses. That has a hard ceiling. Once a name has been resolved the guard sees only an address, and one address can serve thousands of sites. It cannot see the name inside an encrypted connection, a port's purpose, a method or a path.
The egress broker is a second layer for sandboxes that need more than that. It is an
HTTP proxy inside the kosa8d service, one per sandbox. A brokered sandbox's address
policy is deny, and the broker is its only way out. Because programs have to ask the
broker for each destination, every request names where it is going.
- By name, not address. A request names its host, either as
CONNECT host:443or as a plainhttp://host/…URL. The same name rules apply: exact names, and*.domainwildcards. - Ports. A listed host is reachable on 443 and 80. Any other port is refused as
port not allowed for host. - Methods, for plain HTTP. On an unencrypted request the broker sees the method and the path, records both, and can hold a host to a list of methods.
- CONNECT tunnels. HTTPS goes through a tunnel. The broker checks the name and port, then carries encrypted bytes it does not open.
- The address is vetted once. The broker resolves the name on your computer, refuses private and internal addresses as the guard does, and connects to the address it checked, so a second lookup cannot redirect it.
- Credentials. For a named upstream the broker can add an API key the sandbox never holds. That is its own page: Keep API keys out of the sandbox.
A kit with credentials: gets a broker automatically, and the hosts in its
egress_allow become the broker's list, each on ports 443 and 80. The sandbox is given
HTTPS_PROXY and HTTP_PROXY pointing at the broker. These results are
from inside one such sandbox, which allowed example.com:
| Attempt | Result |
|---|---|
http://example.com/ through the proxy | allowed The page is returned. |
http://neverssl.com/, not listed | refused HTTP/1.1 403 Forbidden |
CONNECT evil.example:443 | refused HTTP/1.1 403 Forbidden |
CONNECT 169.254.169.254:80 | refused 403 |
Straight to 1.1.1.1, around the proxy | refused Connection refused, by the guard |
nslookup example.com | refused REFUSED: even an allowed host is resolved by the broker, not the sandbox |
Unlike the guard, the broker records what it allowed as well as what it refused:
sandbox.egress.allow {"kind":"http","host":"example.com","port":80,"method":"GET","path":"/","status":200,"bytes_in":577} sandbox.egress.deny {"kind":"http","host":"neverssl.com","port":80,"method":"GET","path":"/","reason":"host not in allow-list"} sandbox.egress.deny {"kind":"connect","host":"evil.example","port":443,"reason":"host not in allow-list"}
The refusal the program receives is one line that says what to change, for example
kosa8: neverssl.com is not in this sandbox's egress allow-list; add it to the sandbox's
egress policy (an exact name does not cover its subdomains).
The broker admits single hosts, so a brokered kit's egress_allow takes names,
*.domain wildcards and IPv4 addresses, but not CIDR networks. A program that ignores
the proxy settings reaches nothing at all.
The broker's own rule format can carry a port list and a method list for each host. A kit
file has no field for either today: every host in a kit's egress_allow gets ports
443 and 80 and any method.
The pinhole
The broker listens on your computer's loopback interface, 127.0.0.1, on a port
chosen when the sandbox is created. The sandbox reaches it at 192.168.127.254, an
address on its own private network that kosa8 maps to your loopback.
That mapping needs care. It is by address, which means every port, which means every service listening on your computer's loopback: a database, a development server, an SSH daemon. So the mapping is never set up without the guard's check in front of it, and the check is narrow.
- The sandbox is told the broker's address and port in its environment.
- The guard checks each frame sent to that address: it must be TCP, not a fragment, and to the broker's port.
- A connection to the broker passes.
- A connection to another port, here SSH on 22, is refused before anything on your computer is dialled.
- So is everything else listening on your loopback.
Three things keep the hole one port wide. The check runs before the policy's own list, so a list
that happens to cover 192.168.127.254 with a wide network does not widen it. A
fragment has no port to check, so fragments to that address are refused. And kosa8 refuses to
start a broker beside a policy that has no guard at all (any): a broker next to an
open network would be optional.
Address rules or the broker
| Address rules (the guard) | The egress broker | |
|---|---|---|
| Hostname | Only at lookup time. After that it sees addresses, and cannot see the name inside TLS. | yes Every request names its host. |
| Port | no An allowed address is reachable on every port. | yes 443 and 80 for a host a kit lists. |
| Method | no | partly On plain HTTP and credential routes. Not inside a CONNECT tunnel. |
| Path | no | partly Limited on credential routes; recorded for plain HTTP. Not inside a tunnel. |
| Credential | no The sandbox must hold its own key. | yes Added on your computer; the sandbox holds a placeholder. |
| Allowed traffic in the audit log | no Refusals only. Allowed frames are counted. | yes Each allowed request or tunnel is an entry. |
| Protocols | Anything over IPv4: TCP, UDP, ICMP. | HTTP, and TCP through a CONNECT tunnel. |
| Networks (CIDR) | yes | no Single hosts. |
| Programs must cooperate | no It is transparent. | They must use the proxy settings. Those that do not reach nothing. |
Use address rules for a build sandbox that needs a registry and nothing sensitive. Use a brokered kit when the sandbox runs an agent with a key.
Defaults
What a sandbox can reach when nobody said anything depends on who created it.
| Created by | Default | Why |
|---|---|---|
You, withkosa8 sandbox create | any | Unless you pass --egress or --egress-allow, or your organisation's
signed policy sets default_egress, which is then applied instead. |
| An AI agent, with the MCP sandbox_create tool | deny | The agent gets a network only by asking in the call's arguments: egress_allow
with the hosts it needs, or egress: any. Both are in the audit entry. Where the
organisation sets default_egress, that is applied instead. |
| A built-in agent kit | brokered | claude, codex, gemini and opencode run
with the network closed except through the broker. copilot has an address
allow-list. shell has no network. |
| Your own kit | whatever it says | A kit that sets neither egress nor egress_allow and has no
credentials is open, and the run says so: kosa8: network UNRESTRICTED — this kit sets no
egress_allow. |
An agent-created sandbox with no network can still pull images: the service fetches those on
your computer, not from inside the sandbox. If an agent sets both arguments in a way that
conflicts, the call is refused: egress_allow already limits the network to the hosts it
lists; do not also set egress to "any".
Every kosa8 agent run prints its network in the first lines, one of:
kosa8: network closed except through the egress broker: registry.npmjs.org, statsig.anthropic.com kosa8: network limited to api.githubcopilot.com, api.github.com, registry.npmjs.org kosa8: network denied
Packet capture
When you need to see exactly what a sandbox said, capture it. Every frame the sandbox sends or receives is written, on your computer, to a pcapng file that opens in Wireshark or tshark. The sandbox cannot see, stop or edit the capture.
# from the first frame, boot included $ kosa8 sandbox create dev --egress-allow registry.npmjs.org --pcap ./dev.pcapng # or on a sandbox that is already running $ kosa8 sandbox pcap dev ./dev.pcapng $ kosa8 sandbox pcap dev --stop $ tshark -r dev.pcapng -Y dns
--pcap FILE on sandbox create captures from the first frame, boot
included. The file rotates: at --pcap-max-mb (default 64) a segment closes and
FILE.2.pcapng follows, and --pcap-files (default 8) is how many are
kept. The SHA-256 of each closed segment goes into the audit log as
sandbox.pcap.segment, so a capture edited afterwards no longer matches its record.
It contains everything the sandbox said in the clear: tokens, cookies, keys sent without TLS. The files are readable only by you. Treat them like the secrets they may contain. See sandbox pcap.
Limits
What this does not do
- IPv4 only. A sandbox has no IPv6 route at all. IPv6 frames are dropped, and an IPv6 entry in a list is refused, not ignored.
- Under address rules, an allowed address is allowed on every port and protocol.
Allowing
registry.npmjs.orgadmits the addresses it resolves to for TCP on any port, UDP and ICMP. If those addresses belong to a content delivery network, every other site served from them is reachable too. The guard cannot tell them apart. - A wildcard lets data out through DNS. With
*.example.comallowed, the sandbox may look up any name under it, and each lookup is carried by your computer's resolver to that domain's name servers. Whoever runs them can read data encoded in the names.kosa8 sandbox egresslists the names asked for; only allow a wildcard over a domain you trust. - A CONNECT tunnel is opaque. The broker knows the name and port, then carries TLS it does not open. Inside a tunnel there is no method or path filtering, no log beyond byte counts, and no check that the name inside the TLS connection is the name that was asked for. A method list on a host does not apply to HTTPS to that host.
- An allowed host that accepts uploads is a way out. Neither layer reads what is sent. Allow few hosts.
- The policy is fixed at creation. To change what a sandbox can reach, create another one.
- The public-suffix list is short. It catches common mistakes, not every domain under which strangers can register names.
- The log is rate-limited. Past 20 refusals a minute for the guard, and 120 entries a minute for each kind of broker request (refused, credentialed, other allowed), attempts are counted, not listed.
- It trusts your computer's resolver. A name means whatever your computer's DNS says it means, apart from the private addresses that are withheld.
Where to go next
Keep API keys out of the sandbox
The broker's other job: a key the agent uses without holding.
Securing an agent
Where network rules sit among the other layers.
The audit log
Read and verify the record of what was refused and allowed.
Policy reference
Set a default network rule for a whole organisation.