When a connection is refused
You cannot diagnose any of this from the client.
You cannot diagnose any of this from the client. The client is told one of two things: connection not allowed by ruleset for every refusal, and command not supported for the one case where the client asked for a wire operation that does not exist. That is deliberate — a proxy that explains itself is an oracle, and a client that could tell "denied by a rule" from "never listed" could map your policy with a handful of probes.
⇒ First act, every time: read the refusal class in the audit record. Not the client's error string, which is the same string for almost every case.
⚠ Nothing emits any of this yet. No run produces one of these classes today and no record carries one, because nothing routes traffic through the judging part (§2). This section is written now because the vocabulary is closed and ratified, and because a runbook written after the first incident is a runbook written under pressure.
The symptom you will actually see today
| you observe | class | what to do |
|---|---|---|
| a run reaches a host no policy names, and nothing refuses it | none — nothing judged the connection | This is the state of the release (top of this document), not a misconfiguration. There is no setting that changes it and no record row that will show it. Enforce at your own network boundary. |
Refusals of the destination
| you observe | class in the record | what to do |
|---|---|---|
| a connection to a host nobody permitted fails | egress-destination-not-allowed | Decide whether the host belongs in the policy. ⚠ This is also what an explicit deny rule produces — before adding an allow, check whether a deny already covers the host (§6.3). |
| one host works on 443 and is refused on another port | egress-port-not-allowed | The rule that governs that host is an allow and its port list lacks the port. Two fixes, and they are not equivalent. Adding the port to that rule opens it for every host that rule covers — under a wildcard that is a far wider grant than the one host you were asked about. Writing an exact allow for that host on the port it needs grants exactly that and leaves the wider rule untouched; it loads cleanly, and it is the narrow answer. What is refused at load is a second rule naming the same hosts whose ports are a subset of the first's with the same action (§4.3 — it changes no outcome), or one naming the same hosts with the opposite action on an overlapping port (§4.2 — a conflict). The precondition is the whole of it: a rule that names narrower hosts is the exception §4.2 and §4.3 tell you to write, and it loads. |
| a permitted name that resolves fine is refused, right after the lookup | egress-address-denied | The name resolved to an address on the hard floor (§5) — most often the metadata endpoint or a private range. Not configurable and not a bug. If the run legitimately must reach a private-range host, the answer is a different network path, never a policy rule. |
| a destination that names the proxy's own address | egress-address-denied (same class) | In the intended per-run socket deployment this cannot fire. If it does, someone has bound the proxy to a TCP port — find out who wired it. |
| refused, and the lookup itself failed | egress-resolve-failed | Ordinary DNS diagnosis on the proxy's host. ⚠ There is no retry — one lookup is all the design allows, so a flaky resolver appears as a refusal rather than as a slow success. Do not read this as a policy decision. |
| a permitted name works, and the same host reached by its IP address is refused | egress-literal-not-recorded | §6.3 — read it before changing the policy. |
| TLS to a permitted name is torn down immediately, before any application data | egress-sni-mismatch | The hostname announced in the TLS handshake did not match the one the policy judged — or the handshake announced no hostname at all, or an unreadable one; all three are this one class deliberately (§3.5). Look for a client that connects by name A and announces name B: a proxy-unaware SDK, a hardcoded host header, a pinned CDN alias. |
| the host string never became a host | egress-host-unparseable, carrying one of the reasons in §6.5 | See §6.5 — one of those reasons is wider than its name. |
| everything was permitted and the connection still failed | egress-upstream-unreachable | The only class that reports the far side rather than a judgement of ours. Diagnose the network from the proxy's host. If you are holding this class, the policy said yes. |
The two cases where your instinct is right and the answer is still "refused"
These are the 3 a.m. calls. In both, nothing is misconfigured and nothing is broken.
The name is permitted; the client asked by IP address
Symptom. api.example.com is permitted and works. The same host, reached by its IP
address, is refused. Class: egress-literal-not-recorded.
Why. The SOCKS5 protocol (RFC 1928) lets a client ask for an IP address and never put a name on the wire at all. When that happens, an allowlist of names has nothing to match and the single-lookup guarantee has nothing to protect:
Resolving once closes the window between the address that was checked and the address that is dialled. It says nothing whatsoever about a request that asked for no lookup at all.
⇒ So resolving once is necessary and not sufficient, and the only thing that closes the remaining door is this: an address destination is refused unless the policy names that address.
What to do — three options, and the first is nearly always right:
- Fix the client to ask by name. The policy is a list of names; a client that dials by IP has already thrown away the thing the policy reasons about. Most often this is a proxy setting, a resolver being bypassed, or a hardcoded address in a config template.
- Add the address to the policy — knowing exactly what that buys. Re-read §4.4: an
address rule is always an
allow, and it is not hostname-checked, so on a shared-IP CDN you have just admitted every TLS site behind that address. - Neither. If the client insists on an IP for a shared address, the correct answer may be that this destination does not get reached.
⛔ What is not the answer. This is not a DNS fault, and there is no resolver setting that helps: there was no lookup to protect.
A host under a deny loses its port diagnostic
Symptom. A host that sits under a deny rule, asked for on a port that is also wrong,
reports egress-destination-not-allowed — with no hint that the port was wrong. Looking at
a policy that allows the parent domain on 443, you reasonably expect egress-port-not-allowed
and go hunting for a port problem the judgement never reached.
The two cases side by side:
*.example.comallowed on 443 → request forapi.example.com:8080→ the governing rule is theallow⇒egress-port-not-allowed;- the same, plus
*.internal.example.comdenied → request fordb.internal.example.com:8080→ the governing rule is the narrowerdeny⇒egress-destination-not-allowed, and the port never entered the judgement.
The trade, named openly. The refusal class deliberately does not distinguish a host you
denied on purpose from a host nobody ever listed. If it did, one probe on any unlisted port
would map your policy rule by rule — the port-specific class would confirm "this host is
known to the policy", and an attacker inside a run would enumerate your allowlist for free.
The port-specific class therefore fires only when the rule that governs a host is an allow.
⇒ This is a security property paid for with an operator's diagnostic, and this paragraph is the invoice. Put it in your own runbook: at 3 a.m. it reads like a bug.
What to do. ⛔ Do not chase the port. Read the policy: find the narrowest rule whose
destination covers the host. If that rule is a deny, the port was never judged, and no change
to any port list will alter the outcome. Either narrow the deny, or add a narrower allow
beneath it — an exact allow inside a denied subtree is legal and is not read as redundant.
Refusals of the connection's ceilings
For a ceiling, the record carries the ceiling that was crossed, never how far the client got — telling a client how far it got is telling it how much further to try.
| you observe | class in the record | what to do |
|---|---|---|
| a client refused during the handshake after sending a lot up front | egress-limit-preface-bytes | The client front-loaded tunnel data before the hostname check could be answered. It counts what your client sent through the tunnel and not the protocol request that opened it, so a long hostname cannot land here and neither can the way your client split its writes. ⚠ The key that raises it is preface_bytes in the policy document's required limits block (§4.6) — a value an operator writes, and since 2026-08-30 raising it does change what the run you opted in with --egress-policy may send, on the SOCKS5 door — the one door a production client reaches (Core.peekPreface, which SOCKS5's Serve reaches through Core.Relay). It changes nothing for a run that passed no flag: that run is handed no document and is bounded by no ceiling (§4.6, §8). |
a line-length refusal | egress-limit-line-length | Two ceilings share this name and the number printed with the refusal says which: the request ceiling — the whole protocol request cost too much, which a client can cross on either door — or the over-long-line ceiling, one line of an HTTP-style CONNECT handshake, which only that door can cross. Both are the wire format's own cost. ⚠ The keys that raise them are request_bytes and line_bytes in the policy document's required limits block (§4.6), and THE TWO KEYS DO NOT BEHAVE THE SAME. Since 2026-08-30, raising request_bytes does change what a run started with --egress-policy may send — it is charged on the SOCKS5 door by requestStructure.charge (socks5.go), which is a door a production client reaches. Raising line_bytes changes nothing on any run: its only reader is CheckLineLength in connect.go, reached only from ServeCONNECT, which is BUILT, NOT WIRED (§2) and has no production caller. ⛔ Neither key bounds anything for a run that passed no flag (§4.6, §8). |
a header-count refusal | egress-limit-header-count | The client sent too many header lines in an HTTP-style CONNECT handshake. This binds that door only — a SOCKS5 request has no header block to count (§9). ⚠ The key that raises it is header_count in the policy document's required limits block (§4.6) — an operator-written value, and ⛔ raising it changes nothing, on ANY run, doored or not. Its only reader is CheckHeaderCount in connect.go, reached only from ServeCONNECT, which is BUILT, NOT WIRED (§2): no production caller carries a client to the door that counts headers, so no run can cross this ceiling. It bounds nothing for a run that passed no flag either (§4.6, §8). |
| a slow client, refused before any data flowed | egress-limit-handshake-deadline | One deadline covers the whole handshake including the hostname check, so you cannot tell which part spent it — by design. ⚠ Two other causes wear this class: a connection handed no budget at all (a wiring defect, refused rather than relayed) and a handshake write that timed out. |
| an established connection torn down after a silence | egress-limit-idle-deadline | Measured on both directions, and a stalled write counts as idleness — deliberately, because with both directions blocked on a write there is nothing else left to observe. ⚠ A protocol where the server speaks first always lands here — SMTP, some IMAP dialects, MySQL — because the hostname check waits for the client to speak. That is a named limit of the design (§9), not something to tune. |
| a long-lived, legitimate, high-volume connection torn down mid-transfer | egress-limit-total-transfer | This is the only bound on the volume of a busy connection (§3.6) — there is no wall-clock limit, so a big clone or an image pull hits this one and nothing else. Size it deliberately. |
| a connection that stopped because the caller withdrew consent | none — cancellation is not a ceiling | It mints no class and adds no seventh ceiling. Do not go looking for a limit; look for the caller. |
Refusals of the wire protocol, and of the host string
| you observe | class in the record | what to do |
|---|---|---|
| a client asking for an inbound socket or a UDP association | egress-method-unsupported | Only outbound connections exist. FTP active mode and some peer-to-peer libraries land here; the client needs a mode that asks for no inbound socket. Not a configuration gap. |
| a client that tries to authenticate to the proxy | egress-auth-unsupported | Turn the client's proxy credentials off. There is no proxy password and none will be added: the socket is the identity (§3.2). ⚠ This is the class most often mistaken for a wrong password. |
| garbage, a truncated header, a wrong protocol version — also a port outside 1–65535 and a client that hung up before saying anything judgeable | egress-protocol-error | Check the client is speaking SOCKS5 to this socket at all. A client configured with an HTTP proxy setting rather than a SOCKS5 one lands here, and will keep landing here: nothing carries such a client to a door of its own, and that transport is a deliberate absence (§8). |
egress-host-unparseable carries the reason as a second token in the record, and each reason
is a way one string could have meant two different hosts to two different readers:
| reason | what was in the host string | what to do |
|---|---|---|
embedded-nul | a zero byte | Almost always a client bug or an injection attempt: a zero byte truncates a hostname in some resolvers and not in others, which is exactly how one string becomes two hosts. |
whitespace | a space, tab or line break — including the Unicode ones such as NO-BREAK SPACE | Fix the string. ⛔ Do not ask for it to be trimmed: trimming is a decision about which of two hosts the client meant. |
userinfo-present | an @ | The client is passing a URL where a hostname goes. [email protected] is one host to a URL parser and a different host to a resolver, and the policy would be asked about the wrong half. |
over-length | ⚠ wider than its name. A hostname or label longer than DNS permits — and also the short end: an empty hostname, a bare ., and example..com | If you go looking for a long hostname you may not find one. Check for an empty hostname or an empty label first: a config template that interpolated nothing produces exactly this. |
idna-refused | an internationalised name the standard rejects — a disallowed character, a mixed-direction violation, invalid punycode | The precise reason is deliberately not passed on: that would be a diagnosis of the client's input. Re-run the name through an IDNA tool of your own. |
egress-unavailable — the run refused BEFORE it started, and every cure is a human act
Everything above §6.6 is about a connection a run made. This one is different in kind: no run started at all, nothing was spawned, no record was written, and there is no audit row to read. It is the ladder in §8's network-layer row refusing to wall the run in — and because the run has not begun, the only thing that can move it forward is an act of your hand.
⇒ The refusal prints on the terminal that issued saphan run, and it usually names its own rung.
Read that rung first: the six answers below have six different acts, and five of them are not
"try again".
⚠ Two egress-unavailable refusals name NO rung at all, and they are raised before the ladder
is walked, so a sentence with no rung token in it is not a row missing from the table below:
a run that reached the wall with no door minted for it, and a run on a GOOS this build has
written no ladder for (the refusal names the operating system instead). Both are engine-wiring
faults rather than host acts — there is nothing to install and nothing to re-probe.
⛔ A refusal about a vendor-confined backend is not on this table either. It is refused under
confinement-unavailable — a different class, with no rung in it. If the sentence you are holding
does not carry the literal egress-unavailable, §6.6 is the wrong chapter for it.
⚠ THE REMOTE LANE MOVED — 2026-08-30. This paragraph used to put ssh in that same sentence
("a spawn that declares an egress policy and lands over ssh … is refused under
confinement-unavailable"), and that is no longer where a far-lane refusal comes from. A remote
spawn is now refused by the far ladder (egress-unavailable, with the rung in it) when the
machine it lands on cannot be walled, and by confinement-unavailable only for the things that
were always its business — an unconfinable backend, or a far workdir that is unnamed or too deep
for the 103-byte ceiling. The far LINUX arm is the refusal you are most likely holding, and
its next act is in §9B.3, not in the table below: re-probing that host answers nothing,
because the rung it lacks is this engine's, not the host's.
Read the rung off the refusal
The sentence carries the literal egress-unavailable and the rung the machine's registry row
reported. Match it to a row here:
| the rung in the refusal | what it means | your act |
|---|---|---|
netns | the linux top rung, and the one this build composes — so a refusal printing it is never the ladder saying "this host has no lever" | Read which of the two refusals you are holding. (a) run.confinement = "container": the ladder composed the cut and the container arm emits neither of the two levers, so it refuses rather than recording a wall it did not build ⇒ re-run the mandate under run.confinement = "engine". (b) The row says netns on a darwin host, where the platform offers no namespace at all and the walk falls to the bottom of the darwin ladder ⇒ Act 0, re-measure that host from an unconfined shell. |
cgroup-v2 | on linux, the host has cgroup v2, mounted and writable, and this engine has no lever that uses it | Act 1 below — make the top rung available. There is no configuration on the host that changes this answer, and re-probing returns the same yes. ⚠ On a darwin row this token means something else — the walk falls to the bottom of the darwin ladder — and the act is Act 0, re-measure the host. |
seatbelt-network-deny | the darwin top rung, and darwin's own composed lever — so, exactly as with netns, a refusal printing it is never "this host has no lever" | The mirror of the netns row, same two readings. (a) run.confinement = "container" ⇒ re-run under run.confinement = "engine". (b) The row says seatbelt-network-deny on a linux host, where the walk falls to the bottom of the linux ladder ⇒ Act 0, re-measure that host. |
separate-uid | the account exists; the packet rule that drops its traffic does not | Act 2 below — a root act on that host. Nothing this engine does can substitute for it. |
none | the row was probed and the host has no lever | Act 1 (on darwin, its darwin equivalent named in Act 2), or move the work to a host that has one (Act 3). |
<none recorded> | the row was never probed. This is a hole, not a verdict — nobody has measured this host | Act 0 below: measure it. |
Where these six rows come from, so you can re-derive them rather than trust them. Four
functions spell a rung into an egress-unavailable sentence: composeEgressNetworkCutLinux,
composeEgressNetworkCutDarwin and the bottom both of them share, unwalledRefusal — all three in
the product — plus containerEgressCutUnemittable in
the product, which prints the rung of a cut the ladder DID compose. The
two rung-less refusals are the first and the last branch of composeEgressNetworkCut.
Act 0 — measure the host (for <none recorded> only)
Run this from an ordinary terminal on the machine being measured, NOT from inside a run and NOT
over a dispatch. A probe executed inside an already-confined process measures the confinement,
not the host: on macOS a nested sandbox_apply is refused outright, and the axis correctly answers
unmeasured rather than no.
Then read the row back with saphan machine list and return to §6.6.1 with the rung it now
reports. ⛔ If the probe cannot run, the axis stays unmeasured and the run keeps refusing.
That is correct. An unmeasured axis is never spent as though it had passed.
Act 1 — make the top rung available to this engine's own user
The rung this build composes is a network namespace. The question is whether an unprivileged
process on that host can create one, which is what the probe tests by running a child in a real
namespace rather than by reading a /proc file:
⚠ The trap, and it has caught a fleet master on this very track: unshare --net without
--user needs CAP_SYS_ADMIN, and its refusal is indistinguishable from the kernel not having
the feature at all. Always test with --user, and never read a failure of the privileged form as
"this kernel has no namespaces".
If it exits non-zero, the act is your distribution's: unprivileged user namespaces are a
host-level setting (on Debian/Ubuntu kernels, kernel.unprivileged_userns_clone; elsewhere
user.max_user_namespaces), and changing it is a root act on that host, deliberate and
recorded. Re-run Act 0 afterwards — the engine believes the row, not your shell.
Act 2 — the separate-UID rung, and the act this engine will not perform for you
A distinct uid is half the rung. The other half is a packet rule that drops what that uid sends, and it needs root:
⛔ Do not read the account's existence as the lever. That is the exact confusion this rung's
refusal is worded to prevent: without the rule, a separate uid is an accounting boundary and the
network is fully open to it. On macOS there is no unprivileged owner-match rule to install at all,
so on darwin this rung's honest answer is to use Act 1's darwin equivalent instead — get the
seatbelt-network-deny axis measured from an unconfined shell (Act 0).
Act 3 — move the work, or drop the claim
Two acts remain, and both are legitimate:
- Cast the run onto a host that carries a rung.
saphan run --machine <id>names one explicitly;--machine auto --require-egress-cutderives one and prints the arithmetic — every admitted machine it considered and the rung each row reported — so you can see which hosts were refused and why. - Drop the egress policy from the run. A run that declares no policy is not walled in and not judged; it gets the same open network every run on this build gets. This is a real choice and it is yours, but make it explicitly: it is the difference between an ungoverned run you decided on and one you did not notice.
⛔ What is NOT on this list, and never will be: a flag that starts the run anyway. There is no
--allow-open-network, no fallback rung and no "warn instead of refuse" mode. A run that declares
an egress policy either gets the wall or does not start, because a policy recorded and enforced by
nothing is worse than no policy — it reads as a control in every report that quotes it.
What no policy can open
Some destinations are refused no matter what any policy says.
What lands in the audit record
⚠ The record surface is BUILT, NOT WIRED (§8) — the row, the store and the table all ship; nothing constructs the store, so no judgement is ever written down. THIS BOX SAID "the record surface is NOT BUILT — nothing…