msb-egress
Emitting, ordering, approving or debugging msb egress rules and secret bindings in msbctl — rule_groups, extra_rules, --net-rule, --tls-intercept, --tls-intercept-port, egress_mode and observe mode, a
Files2
View on GitHubSKILL.md.agents/skills/msb-egress/SKILL.md
GitHubEgress rules and secret bindings
Everything here is verified against msb 0.7.6 and re-checked on 0.7.7. The property that makes this area expensive: almost every failure here looks like something else. A blocked host looks like an outage, a denied placeholder looks like a network drop, and a silently-unapplied rule looks like working code.
Order the rules in exactly this sequence
Sandbox.net_rules() returns one flat ordered list; msb applies it
first-match-wins, so position is semantics:
base— DNS, the Ubuntu mirrors, and a deny of plaintext:80. Always emitted first, fromdefaults.toml. Nothing a project writes can precede it, so a plaintext:80allow can never be added from a project. The fix for a plain-HTTP URL is itshttps://form.- the sandbox's
rule_groups, in listed order extra_rules- one
allow@host:tcp:portper[llm.<name>]endpoint (Sandbox.llms)
Then emit --net-default-egress deny explicitly. Allow rules alone do not
close egress.
Names, DNS and interception are one coupled mechanism
- A hostname rule means HTTPS and nothing else. On 443, msb's TLS interception reads SNI, so a name backed by an address pool is fine. On any other port nothing inspects the payload and the rule degrades to the addresses the name resolved to, which breaks against pools. For SSH, write a rule by address.
- DNS resolution is part of the rule. msb allows a connection based on the addresses it resolved. An unresolvable name fails identically to an unallowed one: NXDOMAIN in the guest, then a refused connection. Never diagnose a refusal as an outage before checking the rule.
- Without
--tls-intercept, every HTTPS hostname rule is a silent deny. The same flag is what makes secret placeholder substitution possible, so the two requirements coincide and neither can be dropped alone. --tls-intercept-portREPLACES msb's default of 443, it does not add to it (spike 17). Any non-443 interception must restate 443 or every other HTTPS rule quietly dies. This is the single easiest thing to get wrong here.allow_private_dnsemits--no-dns-rebind-protection. Needed whenever an allowed name resolves to RFC1918 (a public record pointing at192.168.x.xis normal in a homelab): rebind protection drops the answer, and the failure is indistinguishable from a missing rule.curl --resolvedoes not work around it.- A wildcard suffix does not cover the apex — list
*.example.comandexample.com. - A host only this machine knows goes in a machine-local group, never in a
project. A group defined only in the operator's
config.toml(machine_only_groups()) is written to the registry'sextra_rule_groups, which adds to the project's groups, andprivate_dns_groupsbringsallow_private_dnswith it. That is howmsbctl addlets a self-hosted forge through (ensure_forge_egress()writesforge-<host>). Don't use the registry'sextra_rulesfor this: in a folder sandbox it replaces the project's list instead of adding to it. - Published ports (
--port) are inbound; the egress policy has no say in them, in either mode. The guest server must listen on0.0.0.0.
Secrets: headers, or nothing
msb --secret ENV[:OPTIONS]@HOST puts a placeholder in the guest env and
substitutes the real value host-side, into request headers only (HTTP
Basic auth included) for the named hosts. Query strings and bodies are not
substituted unless the binding opts in with :query / :body. Never put a
credential in a URL, and never work around the restriction.
A placeholder in a request body is destroyed, and that breaks coding
agents. msb kills any request carrying a $MSB_<VAR> where substitution
isn't permitted. The guest just sees TLS drop, which Claude Code reports as
API Error: Connection dropped (ECONNRESET) — and an agent only has to read
$MSB_GH_TOKEN once for it to land in the transcript, which is resent every
turn: a fresh session works, then degrades permanently. Allowing the host does
nothing; this is the secret policy, not the network one.
with_transcript_passthrough() adds passthrough=[AGENT_TRANSCRIPT_HOSTS] to
every binding msbctl emits — that lets the harmless placeholder travel and
still never substitutes the value.
Two authority rules, both enforced in secret_env():
is_reserved_var()(GH_TOKEN, everyCLAUDE_CODE_OAUTH_TOKEN*) may never appear insecret_bindsin any layer: create dies,modifyskips with a warning.- a binding whose
bindings_layer == "project"takes its value fromsecrets/<name>.envonly. Theglobal.envfallback is registry-only.
Applying a change
Network rules, TLS interception, DNS rebind protection, mounts, published
ports, owned disks and the image are create-time: msbctl allow / mount
/ port record them and say a rebuild is needed. msb modify cannot change
them. Full verified matrix: references/msb-change-matrix.md.
Anything that writes a pinned key (PINNED_KEYS: egress, bindings, [secrets],
resources, caches) must call check_policy() before writing — so a pending
foreign change is asked about alone — then approve_policy() on a reloaded
Sandbox. refuse_before_create() runs before a rebuild removes anything. The
project's .msb/ is also mounted read-only (spike 15) as a tripwire only: root
in the guest can unmount it, so the pin is the actual control.
Measuring instead of guessing
msb never logs a denial at any log level (re-checked on 0.7.7 at
--debug/--trace). It logs what it allows: at debug, TLS intercept sni=<host> dst=<ip:port> and a dns:: line for a resolved name, in
~/.microsandbox/sandboxes/<name>/logs/runtime.log. Nothing is logged for a
plain TCP connect on a non-443 port — off 443, a DNS lookup is the only
evidence. That asymmetry is exactly why egress_mode = "observe" has to
unblock before it can measure: no rules at all, --net-default-egress allow,
--log-level debug, --no-dns-rebind-protection (a rebind block is a block,
and would leave no trace). msbctl observe <name> reads it back, flags what no
rule covers, and prints the msbctl allow line.
egress_mode is written to and read from the registry entry only, never
sandbox.toml — that file is committed and guest-writable, so honouring it
would ship an unfiltered sandbox to everyone who clones the project. A
project's value is ignored with a warning.
Anchors in msbctl
Names are the stable handle; grep rather than jumping to a line:
grep -n "def net_rules\|def egress_mode\|def secret_env\|def transcript_hosts\|with_transcript_passthrough\|^AGENT_TRANSCRIPT_HOSTS\|^PINNED_KEYS\|def check_policy\|def approve_policy\|def is_reserved_var" msbctl
grep -n -- "--net-rule\|--net-default-egress\|--tls-intercept\|--no-dns-rebind" msbctl
Related spikes (re-runnable PASS/FAIL, ./spikes/NN-*.sh; several need
host-specific env vars like ALLOW_HOST/ALLOW_PORT on purpose, and each
asserts both the allowed and the denied half): 03-lan-allowlist,
05-hostname-rules, 07-claude-token-secret, 08-gh-token-secret,
10-private-dns, 17-llm-endpoint.
Tests: tests/fake/msb records argv, so assert what reaches msb, and use
assert_not_in_argv() to prove a planted secret does not.
Do not
- never emit
--conf(it is code, not data: it mounts host paths and interpolates${VAR}from msb's environment, which holds the bound secrets) - never add egress or
egress_modeto a project'ssandbox.tomlunless it is genuinely portable, and never a machine-only group there — every other clone dies on "unknown rule group" - never move a credential into a query string to make it substitute
- never assume a hostname rule covers a non-443 port
Background
docs/content/egress.md— the user-facing page (same repo; read the file, the rendered site isn't reachable from a sandbox)docs/content/configuration.md#the-policy-pin— which keys are pinneddefaults.toml— the shipped rule groups and the comments on what each is for; new groups go there, never in a template skeleton