#Sandbox network policy
This page states what a colony's network policy allows and denies. It covers microsandbox (msb) 0.7.6 as the harness drives it (the pin since #1096; 0.7.x since #639). The source claims below were read at the v0.6.18 tag, commit fa3e439; the behaviour they describe was re-verified against the v0.7.3 binaries. 0.7.4 to 0.7.6 change no flag the harness passes; their two secret-policy changes are under Secret scanning since 0.7.4. #303 and #304 build on it.
- Provenance.
vendor/vendor.lock:5-7pins prebuilt v0.7.6 release tarballs by sha256, not source. Nobody has verified that those binaries were built fromfa3e439. - Which
msbruns.COLONIZER_MSBwins, then the vendored binary, then a host install, thenmsbonPATH(crates/colonizer/src/config.rs:60-67). This page describes the 0.7.6 pin. - Verified or inferred. Claims read from source are stated plainly. Claims reasoned from source but not tested on a running colony are marked (inferred).
#How the harness passes network flags
- Boot only.
crates/colonizer/src/sandbox.rs:68-76passes one--netwith the profiles joined by,(open mode),--net-default-egress denywhen the mode names no profile, and one--net-ruleper rule. They go only to the twomsb runinvocations — detached with--replace(bootincrates/colonizer/src/sandbox.rs), called fromcrates/colonizer/src/boot.rs:1751throughLocalBackendincrates/colonizer/src/execution.rs, and the attached one-shot (run_onceincrates/colonizer/src/sandbox.rs), called fromcrates/colonizer/src/verify.rs:633. The othermsbcalls (rm,ls,image list,pull,--version) take no network flags (crates/colonizer/src/sandbox.rs:87-118and:186-240,crates/colonizer/src/status.rs). - No other policy flags. The harness passes no
--dns-nameserver,--no-dns-rebind-protection, pool or--deployment-profileflag. Besides--netand--net-rule, the flags that change networking are the-ppublish in risk 5, the Claude credential's--secret, and, in allowlist mode,--net-default-egress deny(crates/colonizer/src/sandbox.rs:62-79). - Single-tenant by default. Without
--deployment-profilethe CLI leaves the default (common.rs:1342-1344), which isSingleTenant(domain.rs:247-254). The multi-tenant floor (network.rs:185-186,network.rs:294-301) then does not apply, unless the host overrides the profile. - Host override (inferred). A
deployment_profilein microsandbox's host-wide config overrides the profile a sandbox asks for, on create and on restart (config/mod.rs:151-161,local/mod.rs:231-260,create.rs:104,sandbox/mod.rs:163). That file is~/.microsandbox/config.json, unlessMSB_CONFIG_PATHorMSB_HOMEpoints elsewhere (config/mod.rs:863-871,utils/lib/lib.rs:169-176). The harness does not isolate it: the only variable it sets onmsbis the secret's (crates/colonizer/src/sandbox.rs:66). If the file saysmulti-tenant, the floor isfrom_profiles([Public]), checked alongside the colony's policy (poll.rs:395-399). That blocks the scoped Host allows, so the gateway and Headscale become unreachable, and published ports are dropped (network.rs:523-583). The result is stricter: colonies break rather than gain reach. - Out of scope. The Claude credential's
--secret, passed when the agent needs Claude (crates/colonizer/src/boot.rs:1551-1567), is not covered here. The egress policy is built from--net,--net-ruleand, in allowlist mode,--net-default-egressalone (common.rs:848-967). - TLS interception. A
--secretalso turns on TLS interception (common.rs:2369-2380,builder.rs:870-887), by default on TCP 443 with UDP to that port dropped (domain.rs:2464-2479,domain.rs:2591-2593). This changes TCP 853 and UDP 443 handling below.
#Which colonies get which profile
In the default open egress mode every colony runs with the public profile and nothing more. Host loopback is reached through explicit, port-scoped --net-rule allows, never the broad host profile. Handing a colony the host profile would open every host-loopback port to the untrusted agent — including the cockpit control API on 127.0.0.1:7878 — so the harness no longer does (#375); it opens only the two ports a colony is meant to reach. Allowlist mode (#303, see Egress policy below) names no profile at all.
- Mesh on is
modules.mesh_enabled()and the vendoredheadscale,tailscaleandtailscaledbinaries present. It adds anallow@host:tcp:<control>rule for the headscale control port and the WireGuard rules (colony_networkincrates/colonizer/src/boot.rs). - Any provider adds an
allow@host:tcp:<gateway>rule for the provider gateway port. There is one route per provider inproviders.json, whether or not the colony uses it (colony_routesincrates/colonizer/src/providers.rs).
The table is the harness-owned part of the rules; every boot then adds the DNS allow and the always-blocked deny set of the egress policy behind them.
| Mesh | Providers configured | --net | --net-rule (harness-owned) |
|---|---|---|---|
| On | Any | public | allow@host:tcp:<control>, one WireGuard rule per host IPv4, allow@host:tcp:<gateway> |
| Off | One or more | public | allow@host:tcp:<gateway> |
| Off | None | public | None |
Explicit rules are matched before the profile rules, and the profile rules allow nothing on the Host group, so the default deny closes every other host-loopback port (cockpit API, headscale metrics/gRPC, and any other service on loopback). The two ports that are opened:
- Provider gateway. It binds
127.0.0.1:41750by default (COLONIZER_GATEWAY_BIND, parsed once as an IP:port socket address; a malformed value — a hostname included — refuses startup rather than falling back,crates/colonizer/src/config.rs:70). The guest getshttp://host.microsandbox.internal:<gateway port>/providers/<id>(colony_routesincrates/colonizer/src/providers.rs) inCOLONIZER_MODEL_ROUTES(crates/colonizer/src/boot.rs:1089-1094). - Headscale control. It listens on
127.0.0.1:<control>(crates/colonizer/src/mesh.rs:256), default 41740 (crates/colonizer/src/modules.rs:687). The guest logs in tohttp://host.microsandbox.internal:<control>(crates/colonizer/src/mesh.rs:124-126).
The WireGuard rules (direct_path_rules, crates/colonizer/src/mesh.rs:469-493) are allow@<ip>:udp:<udp_port>, with udp_port defaulting to 41743 (crates/colonizer/src/modules.rs:688), the port the harness node listens on (crates/colonizer/src/mesh.rs:198-199).
- Addresses. One rule for every IPv4 that
ip -4 -o addr show scope globallists. If that yields nothing, everyinetaddressifconfiglists is used instead. Both skip127.*,tailscale*andutun*(crates/colonizer/src/mesh.rs:559-620). This is one rule per address, not one rule overall. - Order. Explicit rules go before the profile rules (
common.rs:920-922). Profile rules only allow, so these rules open that UDP port even on private-range host addresses, whichpublicleaves to the default deny. - Read once. The list is taken at each boot (
crates/colonizer/src/boot.rs:1624) and fixed for the life of the microVM (see Runtime changes).
#How microsandbox enforces policy
#Implementation
- Host-side. The network is a smoltcp userspace stack in the host
msbprocess, behind libkrun virtio-net (backend.rs:1-10,poll.rs:1-6). Enforcement happens outside the guest. - TCP is checked at SYN (
poll.rs:360-411). It is checked again, after the guest's connection is accepted but before the upstream dial, only when the policy has domain rules (tcp/proxy.rs:195-216), which the harness does not pass. A TLS-intercepted port is also checked against the SNI (tls/proxy.rs:184-190). - UDP is checked per datagram (
poll.rs:764-772). - ICMP is echo only and policy-checked (
icmp/relay.rs:1-8,icmp/relay.rs:173-184).
#Rule order and defaults
- Profiles.
public,privateandhostcompose;allandnoneare terminal and cannot be combined with them (common.rs:342-358,common.rs:871-917,types.rs:69-78). The harness passes onlypublic, plus explicit rules. - Expansion. Any profile prepends one DNS rule: egress to the gateway on UDP and TCP port 53 (
types.rs:827-835). Each profile then adds oneallow egressrule for its group, with any protocol and any port (types.rs:295-331,types.rs:769-771). - Defaults. Egress defaults to deny and ingress to allow (
types.rs:326-330). - Evaluation. First match wins, per direction, then the default (
types.rs:1-20,types.rs:448-491,types.rs:545-562). Rule grammar is atnet_rule.rs:6-17.
#Destination groups
Each address falls into one group, tested in this order (destination.rs:40-60). Only IPv4-mapped ::ffff:a.b.c.d is unwrapped first (addr.rs:14-22). 0.0.0.0 and :: match no group at all.
| Order | Group | Ranges | Source |
|---|---|---|---|
| 1 | Host | This sandbox's gateway IPv4 and IPv6 | destination.rs:65-70 |
| 2 | Metadata | 169.254.169.254 (IPv4 only) | destination.rs:115-123 |
| 3 | Loopback | 127.0.0.0/8, ::1 | destination.rs:72-77 |
| 4 | Private | 10/8, 172.16/12, 192.168/16, 100.64/10, fc00::/7 | destination.rs:79-98 |
| 5 | LinkLocal | 169.254/16, fe80::/10 | destination.rs:100-113 |
| 6 | Multicast | 224/4, ff00::/8 | destination.rs:125-130 |
| 7 | Public | Everything else | destination.rs:57-58 |
#public
- Allows every protocol and port to the Public group, plus DNS to the gateway.
- Denies private ranges, link-local, the metadata address, loopback and multicast. No rule allows those groups, so the default deny applies.
- Host. The gateway is its own group, so under
publicalone only the port 53 rule reaches it (types.rs:827-835). Host loopback is not reachable.
#host
The harness no longer passes this profile (see Which colonies get which profile and #375); it is documented here because the Host destination group is still used by the scoped allow@host:tcp:<port> rules the harness does pass, which reach the same gateway addresses on the two allowed ports only.
- Allows the Host group on every protocol and port (
types.rs:316-323). It adds topublic. Host is classified before Private, so the gateway is Host even though the default IPv4 pool is172.16.0.0/12(destination.rs:40-60). - Gateway addresses. Each sandbox gets a
/30from172.16.0.0/12and a/64fromfd42:6d73:62::/48(network.rs:601-656,common.rs:377-385). A family exists only when the host has a route for it (network.rs:206-229). - Name.
host.microsandbox.internal(lib.rs:37) is written to the guest's/etc/hosts(msb agentdnetwork.rs:106-115) and answered by the DNS forwarder (forwarder.rs:313-320). - Loopback rewrite. TCP to the gateway IPv4 is dialled to host
127.0.0.1, falling back to::1; TCP to the gateway IPv6 goes to::1first, then127.0.0.1(poll.rs:817-835). UDP to a gateway goes to host loopback of the same family (poll.rs:837-856). - Net effect (inferred). Every TCP and UDP port on host loopback is reachable at the network level, other than the DNS ports below and, with TLS interception on, UDP 443 (
poll.rs:743-749).
#DNS
- Resolver. The guest's
resolv.confnames the gateway (network.rs:410-432, msb agentdnetwork.rs:568-591). - Upstream. The forwarder uses the host's resolvers:
/etc/resolv.confon Linux, the SystemConfiguration store first on macOS (nameserver/mod.rs:1-14,nameserver/mod.rs:77-84). - Name policy is checked first; a denied name gets NXDOMAIN (
forwarder.rs:289-301). The profile DNS rule matches every query over UDP or TCP port 53 (types.rs:600-626). The harness passes no name rules, so policy refuses no name. - Any resolver IP. UDP/53 and TCP/53 to any address go to the forwarder (
poll.rs:1108-1113,poll.rs:551-576). The TCP SYN skips the egress check (poll.rs:362-366). A query aimed at a non-gateway resolver is then checked against egress policy for that IP (forwarder.rs:797-828). - Other DNS ports. UDP 853, 5353, 5355 and 137 are dropped (
ports.rs:72-78,poll.rs:751-760). TCP 853 is refused unless TLS interception is on; then it skips the egress check and goes to the forwarder like TCP/53 (poll.rs:375-382,poll.rs:579-600). - DNS over TLS (inferred). The forwarder checks such a query against policy as TCP 853 (
transport.rs:37-53,forwarder.rs:834-846). The profile DNS rule covers port 53 only (types.rs:827-835), and no colony rule allows the Host group on 853, so the query gets NXDOMAIN (forwarder.rs:289-301). Untested. - Rebind protection is on by default (
config/types.rs:255-263). An A or AAAA answer in the block lists gets NXDOMAIN unless a rule with no port filter, profile rules included, allows that address (forwarder.rs:347-376,forwarder.rs:759-776,types.rs:371-393,filter.rs:12-53). The lists cover64:ff9b:1::/48but not64:ff9b::/96. - Rebind gaps (inferred). Under
public, a listed range that the classifier puts in Public, such as198.18/15or64:ff9b:1::/48, is allowed by the profile rule and so still passes. Under thehostprofile, so does the gateway address; the harness's rules all name a port, so they exempt nothing. Rebind protection filters answers only; a direct IP connection never passes through it.
#Runtime changes
Rules cannot change while a colony runs.
- Read once. The policy is cloned at start (
network.rs:326) and held in an immutableArcby the poll loop (poll.rs:263). - No modify path.
SandboxModificationPatchhas no network field (modify.rs:26-79). - So a change needs a recreate. The harness recreates on every boot with
--replace. The egress policy rides this: a settings change reaches a colony at its next boot or on stop plus Resume, which re-resolves global and org settings. No live allow/block/reset API is offered.
Composable profiles arrived in v0.6.7 (2026-07-24.mdx:9-18). The upstream changelog has no entry for any 0.7.x release; its latest lists v0.6.18 (2026-09-11.mdx:9).
#IPv4 preference in the guest (#946)
- The gap. A colony's microVM gets an IPv4 gateway, and an IPv6 one only where the host has a route for the family (
network.rs:206-229); this guest has neither a global IPv6 address nor a::/0route, so every IPv6 destination it is given is unroutable (inferred). The names a colony fetches from are dual-stack — the crates.io CDN, PyPI, npm's registry — and glibc's default RFC 6724 table ranks the native IPv6 destination::/0above the IPv4-mapped::ffff:0:0/96, so the AAAA record a resolver relays wins on paper and fails on the wire. A colony met this as 403s from the crates.io CDN while its cargo, curl, node and python all dialled an address with no route. - No host-side seam. Nothing above fixes it:
msbtakes--net,--net-ruleand--net-default-egress(see How the harness passes network flags) and offers no resolver, route or netfilter knob beyond them. The boot script is the one place the harness runs as the guest's root before the agent does, and is already where the path policy and the kernel masks are applied. - The fix.
boot.rs:2241splicescrates/colonizer/src/ipv6.rs:53intoBOOT_SCRIPT(expanded byboot_script,crates/colonizer/src/boot.rs:2324, at the write,crates/colonizer/src/boot.rs:1833), and the block appends two lines to the guest's/etc/gai.conf: ``precedence ::ffff:0:0/96 100 precedence ::/0 10`Both are needed. Ranking only the IPv4-mapped range at 100 leaves a **native** AAAA under::/0at glibc's default rank, still ahead of the mapped range, and still preferred; demoting::/0` is the half that does the work, and the mapped line keeps an IPv4-mapped answer from being demoted with it. - An operator's table wins. A
/etc/gai.confthat already carries an activeprecedenceline is left byte for byte alone, because the harness cannot know why they ranked families. A stock Debian image's own table ships commented out (#precedence ::ffff:0:0/96 10), which is not a preference, so the real lines go in beneath it (inferred). - The event. When the guest has no global IPv6 address or no default route — read from
/proc/net/if_inet6and/proc/net/ipv6_route, with no network tool and no timeout — the block appends onelogevent to the guest's/var/lib/colonizer/events.jsonl, at levelinfowhen the preference was written or was already there andwarnwhen it could not be (which also says so on stderr). With working IPv6 there is nothing to report and nothing is written. agentd reads that log from the start, so the line is counted, replayed to the host verbatim, and numbering continues at 2 (inferred) — no colony has booted with this block yet. - Every boot (inferred). The guest rootfs is discardable, so the preference is reapplied at each boot rather than persisted; the block is idempotent, and a
/etcit cannot write costs the preference, never the boot. Untested on a running colony.
#Not a package mirror
An agent that meets these 403s can reach for a third-party cargo, npm or pip mirror to get past them. That is a supply-chain hazard: the colony's dependencies would come from whoever answers, not from the registries the fence and the allow-list already cover (Egress policy). Agents must not switch a colony to a mirror to work around a network failure, and the harness fixes this one properly instead — in the guest's resolver policy, where no colony's configuration is involved.
#Colony secrets and the fence
A colony secret (POST /api/secrets/colony, see protocol.md) names the hosts its value is for, and boots as msb --secret ENV@hosts: msb swaps the placeholder for the value only on TLS to those hosts. The hosts must be public DNS names, and the public profile already reaches the Public group, so in the default open egress mode no extra --net-rule is added for them (allowlist mode adds an explicit allow@<host>:tcp:443, see Egress policy below). Private, loopback and link-local destinations stay behind the default deny, which is why the API refuses localhost, IP literals and internal names such as *.internal or *.local rather than accepting a host the colony could never reach.
#Secret scanning since 0.7.4
msb blocks a request that carries a secret's placeholder where it may not go, and logs secret violation in the sandbox's runtime.log ($MSB_HOME/sandboxes/<name>/logs/runtime.log). Two upstream changes after 0.7.3 matter to colonies:
- Header and body are scanned apart (microsandbox#1666, in 0.7.4). 0.7.3 scanned a Content-Length request whole, so a
%or\uearly in the body made it decode the Authorization header's placeholder as a body match (location=body match_form=percent_decoded). The connection closed and the colony sawUND_ERR_SOCKETon every turn of that conversation (#1096). - A placeholder is allowed on its own hosts (microsandbox#1700, in 0.7.5). On a host the secret is for, after the TLS identity check, an unsubstituted placeholder (in a body, say) is forwarded unchanged instead of blocked. Every other host still blocks it.
A sandbox keeps the msb it was started with, so a running colony gets both only after a stop and resume.
#Egress policy (#303)
The fence above now carries a configurable policy on top (#303), built in crates/colonizer/src/egress.rs. There are two classes of traffic in a colony — the agent's own model traffic (the provider gateway, the TLS-edge secret hosts) and everything else the VM does: package managers, curls, the agent's GitHub calls. Both share one fence, and that is the honest state of this slice: the agent and every tool in the guest run as root, so there is no in-guest separation that could scope the policy to one class. Enforcement lives entirely in msb's userspace network stack on the host; the policy below applies to the whole VM — package managers included — and nothing in the guest can see, evade or tell it apart from any other traffic. In-guest hardening (#547) since dropped the agent's capabilities and put it under a seccomp filter, but it did not split network access by process: allowlist mode still means "this VM may reach", not "the agent may reach".
#What is always blocked
Every boot compiles a fixed deny set ahead of every configured rule (egress.rs ALWAYS_BLOCKED):
| Rules | Close |
|---|---|
deny@meta | 169.254.169.254, cloud metadata |
deny@private | RFC 1918, CGNAT 100.64.0.0/10, ULA fc00::/7 (destination.rs:79-98) |
deny@loopback | 127.0.0.0/8, ::1 |
deny@link-local | 169.254.0.0/16, fe80::/10 |
deny@multicast | 224.0.0.0/4, ff00::/8 |
deny@host | the sandbox's own gateway IPs, every port but the harness's allows |
[email protected] | restates meta, so the record reads whole |
[email protected]/8, 100.64.0.0/10, 192.0.0.0/24, 198.18.0.0/15, 240.0.0.0/4, [fc00::/7], [fec0::/10], [64:ff9b::/96], [64:ff9b:1::/48], [2002::/16] | the classifier gaps of Open risks item 2 — addresses destination.rs puts in Public today — plus restatements of private parts, so a classifier change cannot quietly reopen them |
msb evaluates rules first-match-wins per direction (types.rs:338-352). The compiled order is fixed (egress::compile): one allow@dns, the harness's port-scoped infrastructure allows, this deny set, the operator's blocks, the operator's allows. allow@dns comes first because a DNS query is itself evaluated against Host-group rules (types.rs:589-626), so a bare deny@host behind it would otherwise take every lookup down with the rest. Only the harness's own rules precede the deny set, configured allows compile last, and msb's policy cannot be changed on a running sandbox (see Runtime changes) — so no configuration, at either level or in a hand-edited file, can reopen the set. A deterministic fuzz in egress.rs compiles hundreds of random policies against that invariant.
#What is allowed by construction
- The WireGuard direct path (UDP) and the Headscale control port (TCP) when the mesh is on, and the provider gateway port (TCP) when routes exist — as above.
- Gateway port 53 (
allow@dns): msb's own forwarder still answers, and the guest's tailscale runs with--accept-dns=falseprecisely so secret injection keeps using it. - In allowlist mode, the TLS-edge secret hosts on TCP 443 — a colony with an injected credential that could not reach its host would only look fenced. In open mode the
publicprofile already covers them.
#Modes and settings
The sandbox module gains three settings, validated at save time: egress (open or allowlist, default open), egress_allow and egress_block — comma-separated host[:port] entries. A host is an FQDN (lowercase, at least two labels; a leading *. compiles to a suffix rule), an IPv4, a bracketed IPv6, or a CIDR of either (bracketed for IPv6). :port is TCP 1-65535; :0 or no port means every port. Single-label names are refused: in msb's grammar those words are destination groups, not hostnames. An org may pin its own mode and add to both lists in its settings (orgs.json, egress), never remove from them — resolution (egress::resolve) takes the org's mode when it set one and unions the lists, adds the running agent module's declared hosts in allowlist mode (see below), and drops entries a hand-edited file let in that do not parse: an invalid entry can only shrink a colony's reach, never widen it. In allowlist mode resolve also appends a fixed baseline of package-mirror hosts (TOOLBOX_ALLOW: Debian apt, PyPI, npm, crates.io and the Go proxies), after the operator's and the module's entries, so the image's own toolbox and a repository's .colonizer/setup.sh can install from them without the operator enumerating every mirror. open mode takes none of it — its public profile already reaches them. A fleet member is clamped on top of this: when its owner has set a fleet network policy (#690), the fleet, org and repo levels union into a floor the colony may tighten but never loosen, and each refused loosening is listed in the colony's egress record. The dev-server previews that reach a colony's guest ports ride the mesh, not this policy.
| Mode | Flags | Behaviour |
|---|---|---|
open (default) | --net public | The profile fence above, with the deny set behind the harness's allows |
allowlist | --net-default-egress deny, no --net | No profile allow at all; egress falls to the default deny unless a rule above or an allow-list entry allows it |
--net none looks like the allowlist encoding but is not one: NetworkPolicy::none() denies ingress too (types.rs:272-279), and the mesh-off published port needs ingress. --net-default-egress deny with no --net lands as { default_egress: deny, default_ingress: allow, rules: <the --net-rule tokens> } with no profile rules (common.rs:848-967).
Each boot records what it resolved — mode, allow, block, sources, module, always_blocked, rules, profiles, applied_at — at <session dir>/egress.json, and GET /api/sessions/{id}/egress serves it (404 for a colony that last booted before this existed), so the fleet view can answer what a colony could reach, and whose hosts widened it, without reading the boot log.
#Hosts an agent module declares
Each agent module's module.json lists the hosts its runner needs under egress, in four optional arrays: api, auth, telemetry and extra. Entries are bare hostnames; a leading *. covers subdomains. The claude-code module, for example, declares api.anthropic.com as its API host and *.sentry.io among its telemetry hosts. Loading a module checks the section: an unknown category or a non-hostname entry is refused, and every host in the module's secrets[].hosts must be covered (parse_egress and read_agent in crates/colonizer/src/modules.rs).
The running module's declaration is part of the fence (#601). In allowlist mode a colony's allow list is the union of the module's api, auth and extra hosts and the operator's egress_allow (global and org), so the agent reaches its vendor's hosts without the operator restating them. The module's telemetry hosts are deliberately left out: there is no opt-in concept for telemetry, so an operator who wants those hosts lists them in egress_allow themselves. A module host is validated and compiled exactly as an operator entry is — lowercased, with a bare *. a suffix rule — and a host egress_allow already names is not repeated. Nothing here can reopen a blocked destination: the operator's egress_block and the always-blocked deny set still compile ahead of every allow, module-declared or not. open mode is unchanged, because the public profile already covers these hosts.
Each boot records which module contributed and the entries it added: the module object, {agent, allow}, in <session dir>/egress.json, served by GET /api/sessions/{id}/egress (see Modes and settings).
When a tool call is denied, the claude-code runner adds a denial: {class, hint} field to the errored tool_result event and tells the agent the same hint once per class; see boundaries.md for what is a boundary and what is only guidance.
#Runtime changes under the policy
Unchanged by this slice: msb's policy is fixed per sandbox (see Runtime changes), and no live allow/block/reset API is offered. A policy change reaches a colony when it next boots — every boot runs --replace — or on stop plus Resume, which re-resolves global and org settings. Because enforcement lives in msb on the host and not in the mesh, applying a policy does not depend on the mesh being up: a wedged mesh (#167) delays the agent, not the fence.
#Host-loopback listeners
A colony's profile is public alone (colony_network in crates/colonizer/src/boot.rs), or no profile at all in allowlist mode. Besides the profile's port-53 DNS rule, which the forwarder answers (types.rs:827-835), its only Host-group allows are the Headscale control port when the mesh is on (crates/colonizer/src/boot.rs:473) and the gateway port when providers are configured (crates/colonizer/src/boot.rs:477-479). A host rule names the Host group (net_rule.rs:563-574), so it covers the gateway's IPv4 and IPv6, and TCP to either is dialled to host loopback (poll.rs:817-835). Every other Host port falls to the default deny. The table lists what the harness host has on loopback, at default ports. Its .rs paths are under crates/colonizer/src/ unless given in full.
| Port | Default bind | Serves | Auth | Reachable from a colony |
|---|---|---|---|---|
| 7878 | 127.0.0.1, COLONIZER_BIND (config.rs:52) | Cockpit: every /api/* route, including the session terminal WebSocket, and the web UI (api_routes and router in server.rs) | Per-install API token in Authorization: Bearer or the colonizer_token cookie (auth.rs), or a scoped col_… API token as a Bearer header only (api_tokens.rs). host_guard checks Host, and since #375 rejects a cookie-authenticated write or upgrade whose Origin is missing or does not match (host_guard in server.rs) | Default deny since #375 |
| 41750 | 127.0.0.1, COLONIZER_GATEWAY_BIND (config.rs:70) | Provider gateway, /providers/{id}/{*path} (router in gateway/mod.rs) | Per-colony token in x-colonizer-colony, matched against live colonies (COLONY_HEADER and colony_for_token in gateway/mod.rs) | Allowed when providers are configured |
| 41740 | 127.0.0.1, mesh control_port (mesh.rs:256, modules.rs:687) | Headscale control server | Joining needs the pre-auth key minted per VM (mesh.rs:374-403); other routes not verified | Allowed when the mesh is on |
| 41741 | 127.0.0.1, control port + 1 (mesh.rs:257, mesh.rs:301) | Headscale metrics | None set by the harness; not verified | Default deny since #375 |
| 41742 | 127.0.0.1, control port + 2 (mesh.rs:258-259, mesh.rs:302) | Headscale gRPC | grpc_allow_insecure: false and no TLS configured; not verified | Default deny since #375 |
| 41744 | 127.0.0.1, mesh socks_port (mesh.rs:200-201, modules.rs:689) | Harness tailscaled SOCKS5, which the harness uses to dial colonies (mesh.rs:461-465) | None (tailscale v1.102.4 proxy.go:98-101, socks5.go:158-172) | Default deny since #375 |
| Random, per colony (mesh off) | 127.0.0.1, published to guest port 7070 (sandbox.rs:77-79, boot.rs:1633-1634) | Another colony's colonizer-agentd: health, events, PTY, shutdown | Per-colony bearer token (boot.rs:782, sessions/agentd.rs agentd_token, crates/colonizer-agentd/src/main.rs:185-191) | Default deny since #375 |
| Any | The operator's, such as the local provider preset's 127.0.0.1:8080 (web/src/components/SettingsDialog.tsx:2679) | Anything else on host loopback | Its own | Default deny since #375; the gateway still proxies to a configured provider |
Still open, all (inferred) and untested:
- Gateway bind. The gateway allow is built from the configured socket address (
colony_networkincrates/colonizer/src/boot.rs), not from the listener that bound; a bind failure is only logged (serveincrates/colonizer/src/server.rs). If the gateway fails to bind, whatever else holds that port is reachable. - IPv6 loopback. TCP to the gateway's IPv6 address is dialled to host
::1first (seehost), so an unrelated service on[::1]:<allowed port>would be reached. - Headscale. The control-port allow exposes Headscale's whole HTTP surface on that port, not just node registration. Auth on its other routes is not verified.
- WireGuard. The WireGuard rules name the host's LAN or public IPv4s (
crates/colonizer/src/mesh.rs:469-493), not the gateway, so they open no loopback port.
#Open risks
hostreaches every host-loopback port — closed for colonies. Thehostprofile opens every host-loopback port at the network level. The harness used to hand it to effectively every colony (mesh is on by default, and any configured provider added it), which let the untrusted agent drive the cockpit control API and any other loopback service (#375). The harness now passespublicplus port-scopedallow@host:tcp:<control>/allow@host:tcp:<gateway>rules instead, so only those two ports are reachable and the default deny closes the rest. The profile itself is unchanged upstream; the risk applies to any caller that still passeshost. The harness's security review is in audit.md.- Classifier gaps — closed for colonies by #303, (inferred) upstream. These fall through to Public under
public(destination.rs:40-130): IPv4255.255.255.255and the rest of240/4,0.0.0.0/8other than0.0.0.0,198.18/15,192.0.0/24, and the documentation ranges; IPv6 NAT6464:ff9b::/96and64:ff9b:1::/48, 6to42002::/16, site-localfec0::/10. Every one but the documentation ranges is now named in the always-blocked set, which precedes the profile rules, so a colony cannot reach them in either mode. What remains: - Documentation ranges (192.0.2.0/24,198.51.100.0/24,203.0.113.0/24,2001:db8::/32) are still Public and unblocked; they are not routable on the internet, so the practical reach is a translator or proxy that answers them. - NAT64. microsandbox dials these as ordinary public addresses (poll.rs:833). On a host network with a NAT64 translator, an address under one of these prefixes can embed a private, link-local or metadata IPv4 address. A translator must drop the well-known prefix64:ff9b::/96with a non-global IPv4 address inside (RFC 6052 §3.1). That rule does not bind the local-use64:ff9b:1::/48(RFC 8215 §5). Untested. The deny rules now drop the prefixes themselves; whether a host's translator honours RFC 6052 is still the host network's to say. - The host's own addresses (inferred). A host interface address outside the private and link-local ranges, IPv4 or IPv6, is Public. A colony with
publiccan reach any host service bound to that address or to all interfaces. Untested. - Rules are fixed at boot. A WireGuard rule stays for the life of the microVM after the host's address changes, and a new address gets no rule. Tightening any rule needs a recreate.
- Ingress defaults to allow (
types.rs:326-330). Ingress policy applies to published ports (publisher.rs:569,publisher.rs:614), and the harness adds no ingress rules. It publishes one port, on host127.0.0.1, only when the mesh is off (crates/colonizer/src/sandbox.rs:77-79,crates/colonizer/src/boot.rs:1633-1634). Since #375 other colonies no longer reach that port through host loopback, and, as before, direct guest-to-guest traffic is denied, because other sandboxes' addresses fall in172.16/12orfd42:6d73:62::/48, both Private (destination.rs:79-98), and only a sandbox's own gateway is Host (destination.rs:65-70) (inferred). - Binary provenance. The pinned tarballs are checked by digest, but no one has tied them to
fa3e439, and a non-vendoredmsbcan be picked up instead (crates/colonizer/src/config.rs:60-67).