Advanced: where the door lives, and why it could not live anywhere obvious
For a security reviewer.
For a security reviewer. Everything above describes what the control decides. This section describes the one physical object the control needs — a socket the agent can reach and cannot tamper with — and why placing it took three attempts, two of which were rejected on measurements rather than on taste. If you are auditing this product, this is the section where the trade-offs are, and each one is stated as a price paid rather than a property acquired.
9A.1 The door is a per-run unix socket, and the run refuses to start without it
⚠ Read this section as conditional on ONE act: the run declaring a policy. Every mechanism
below is in the binary and is executed for a run started with saphan run --egress-policy —
which since 2026-08-30 is a thing a run can do (§2, §8). ⛔ The retired wording said none of it
runs "because nothing assigns a run an egress policy", and that is false on this tip; what is
true is narrower and unchanged in practice: no run in this fleet has passed the flag, so what
follows describes no run that has happened yet. For a run that passes no flag it is not the
present conditional but the past tense — no socket is minted, the door is never opened, nothing
here is reached at all.
A doored run is minted its own socket before the agent starts, at
<engine state home>/.saphan/egress/<run-id>/d.sock, mode 0700 on the directory and 0600
on the inode. The proxy binds it, the run's confinement makes exactly that one path reachable,
and the agent is handed the address as ALL_PROXY=socks5h://localhost<path>. When the run
ends, the socket is closed and unlinked.
Failure is closed, at every step. No place to put the socket, a path the kernel cannot hold, a transport that cannot name it, a confinement this engine did not compose, no proxy wired, or a proxy that will not bind — each of these ends the run before the agent starts, recorded, at zero cost. There is no path on which a run that declared an egress policy starts with an uncontrolled network.
9A.2 The 103-byte ceiling, and why a long path is not an option
A unix socket's path is stored in sockaddr_un.sun_path, a fixed C array: 104 bytes on
macOS, 108 on Linux, NUL included. This engine uses 103 — the smaller platform's usable
length — because one policy on two systems means a path that binds on one has to bind on the
other.
This is not a theoretical corner. Measured on this fleet: a worktree root is 128 bytes and a
per-stream temp home is over 100 on its own. Over the ceiling, net.Listen fails with a bare
invalid argument carrying no path, no length and no ceiling — a message that reads like a bug
in the socket and is in fact arithmetic. So the engine refuses by name, before the bind,
printing the path, its length and the ceiling, and saying that the length is the whole path
so a shorter file name does not rescue a deep home directory.
⇒ The consequence a reviewer should take away: the door's location is not a preference. It is constrained to a short, writable, stable directory, and that constraint is what makes the next two subsections necessary rather than fussy.
9A.3 Why the socket is not in the run's own directory
The obvious place for a per-run resource is the run's own directory, and it is the one place this socket may not be: the run directory is bound WRITABLE into the confinement on both platforms — it has to be, because the transcript and the captures land there.
An agent that can write that directory can unlink the socket and bind its own. What would then answer on the path the environment advertises is a program the agent wrote, and every subsequent statement in the audit record — this destination was allowed, this one refused — would be about a proxy that was not this one. A socket the agent can replace is a name, not an identity.
So the door is minted outside every tree the confinement makes writable. The agent can connect to it and can do nothing else to it.
9A.4 Why the agent keeps a private /tmp, and why that is about mounts, not filesystems
On Linux the agent's confinement includes --tmpfs /tmp, which gives the run a private,
empty /tmp. The obvious way to make a short path available would be to drop that flag.
Measured, and it settles the question in the opposite direction. On this fleet's Linux
runner, /tmp is an ext2/ext3 filesystem, not a tmpfs at all — and a file created on the
host's /tmp is still invisible inside bwrap … --tmpfs /tmp, and visible without the
flag.
⇒ The flag is about mount isolation, not about filesystem type. Removing it would not
"stop using a tmpfs". It would show every run the host's /tmp, and each other's files —
a channel between concurrent runs, which is a strictly worse property than the one it would
buy. The flag stays, and the door goes somewhere neither platform's temp root reaches.
9A.5 Bound in as a single file — what the agent can and cannot see
The socket is bound into the confinement as one file. Its directory is never bound. The mechanism differs per platform because the platforms differ, and the difference is worth knowing when you audit one of them:
| how the agent's own door is reachable | how every other run's door is unreachable | |
|---|---|---|
| Linux | one --bind <socket> <socket> — a bind of the inode | the egress root is replaced by an empty tmpfs in this run's mount namespace, before the socket is bound back over it. Another run's socket does not exist in this run's view. Not "exists and is refused" — absent. |
| macOS | (allow network-outbound (literal <socket>)) | the whole egress root is denied for network-outbound and for file-read*, and exactly one literal path is allowed back. Seatbelt is last-match-wins, so the order of those clauses is the mechanism. |
Two properties follow that are worth stating plainly:
- The agent cannot delete its own door. On Linux the socket is a mount point, and
unlinking a mount point fails with
EBUSYeven though the tmpfs around it is writable. On macOS the socket is granted for reaching and reading, never for writing — seatbelt checksunlinkagainst the target path, so a write grant would have handed back exactly the power §9A.3 exists to withhold. - A run with no egress policy is masked too. The root is hidden from every confined run, not only from runs that have a door. Otherwise the one process on the host with no policy at all would be the one that could still dial everybody else's door.
⚠ Named limit, not glossed — and the two arms are NOT equally strong. Linux is STRUCTURAL; macOS is ARGUED. The Linux separation is a namespace: the other run's socket is absent, so there is no rule to evaluate. The macOS separation is a rule, and this release verifies it in three unequal parts:
- Last-match-wins is PROVEN on this product, not assumed. The existing darwin profile
already emits a blanket
(deny file-write*)followed by a later(allow file-write* (subpath <workdir>)), and a test drives a realsandbox-execand fails if the workdir write is refused — which it would be under first-match-wins. The ordering the egress clauses depend on is live-kernel-verified. - The
network-outboundsemantics are NOT measured, and they are the load-bearing half: that seatbelt judgesconnect(2)on anAF_UNIXpath asnetwork-outboundwith a path filter, and that(deny network-outbound (subpath <dir>))blocks aconnect(2)beneath that directory. The idiom is real and is what Apple's own profiles use; this product has not put it in front of a kernel. - The measurement that would settle it, and why it is not in this release. A live
sandbox-execrun of the composed profile with one process listening on a socket under the root and a second attemptingconnect(2)— expectingEPERMfor the other run's path and success for its own literal. macOS refuses to apply a sandbox profile inside a sandbox (sandbox-exec: sandbox_apply: Operation not permitted, exit 71 — measured three times, by the engineering leg and independently by its reviewer), and every leg that touches this code runs inside one. ⛔ Turning that confinement off to get the number is not an acceptable way to get it. The measurement is owed from an unconfined macOS host.
⇒ Treat the macOS clause as designed and asserted. On Linux nothing equivalent is outstanding.
9A.6 The container exemption, stated as narrowly as it is implemented
A run confined by a container gets its door through a bind mount. That collides with a guard this product already has: a rule that refuses to start any container whose argv bind-mounts a unix socket, because a process holding a container runtime's control socket can ask the host daemon to start a second container with any flags at all — which makes every other limit on the run void.
The egress door is also a unix socket, and it is the opposite object: it is the only way out
of a --network none container, and everything reaching it is judged. So the guard has exactly
one exemption, under four conditions, all required:
- a door was minted for this run (an absent exemption exempts nothing);
- the mount source is that door — the same filesystem object (
os.SameFile, so device and inode rather than text), or failing that the same path string in some spelling. Not a prefix, not a parent, not a suffix rule: a path differing by one byte and naming a different object is refused. Identity is what lets this survive a symlink, a hard link and a case- or Unicode-variant spelling; the string comparison behind it covers the door that has not been bound yet and so has no inode to compare; - the path named in the exemption is under this engine's own egress root;
- the path is not a container runtime's control socket, tested the same two ways the guard tests every other mount source — asked again on the one code path allowed to say "no hazard" about a socket.
⚠ Condition 3 constrains the exemption and not the mount source, and an earlier draft of this list said it "so a mis-composed or tampered run cannot name something else" — which a security review measured as false. Round 1's condition 3 did constrain the source; the round that made the comparison survive a symlinked home moved the subject to the exemption. A caller threading an unresolved symlinked door — a path under the root whose target is a directory outside it — therefore gets that outside directory admitted (measured). It does not open in the shipped composition, which hands the guard the already-resolved door path, and it is recorded here rather than in a comment because it is the kind of thing a reviewer must be able to find. It is not closed today; re-narrowing it has to re-answer the symlinked-home break that widening it repaired.
The exemption widens one rung — the "this source is a .sock" test, and the descendant test
below — and nothing else. Privileged mode, host networking, added capabilities, a disabled
seccomp or AppArmor profile, the host user namespace, the host root filesystem and the runtime
sockets are all judged exactly as before.
The same change also made the guard stricter, and the strict rule is the whole neighbourhood of the egress root in both directions:
- upward — the root itself and any path that contains it, up to and including
/, refused for every value of the exemption. A mount that contains the root contains every run's door, and no exemption can make that one door. - downward — every path under the root, at any depth, refused unless it is exactly this
run's own door: another run's socket directory, another run's socket, a deeper path, a path
that does not exist yet, a directory whose name ends
.sock, and this run's own socket directory (the door is a file; its directory is never granted). - every spelling of those — a trailing slash,
.,//, a..traversal that resolves into the tree (including one through a component that does not exist, and one approaching the root from outside), and a symlink whose target is any member of the set.
**AND "EVERY SPELLING" MEANT "EVERY SPELLING THE AUTHOR TYPED" ** On a case-insensitive filesystem — every Mac in the
default configuration — <EGRESS-ROOT>/<another-run-id> and <EGRESS-ROOT> are the same
directories as their lower-case spellings while being different strings. The rung compared
text, so both were admitted: the headline hole above, reachable again through one flipped
letter, on the one platform whose run separation this document already marks ARGUED rather than
STRUCTURAL. Linux was unaffected, because there the upper-case path names a different,
non-existent directory and the mount fails.
The repair is not a better string comparison. Case is one encoding of many, and this
filesystem is also normalisation-insensitive (measured: a directory created with the composed
spelling of café is found through the decomposed one), so a case-folded comparison would have
fallen to the next spelling. The guard now asks the filesystem which object a path names —
device and inode, via os.SameFile — for the source, for every ancestor of the source, and for
the root. Case, Unicode normalisation, redundant separators, .., trailing dots, symlinks and
hard links all collapse before the comparison happens, because it never looks at the text.
It is complete for a path that does not yet exist as well, which is the ordinary case: some
ancestor of any path exists, at worst /, and if the source really is under the root then the
root is one of those ancestors and is found by identity.
THE ROOT ITSELF ALWAYS EXISTS WHEN THE GUARD IS ASKED, AND THAT RETIRES A LIMIT THIS
DOCUMENT USED TO DECLARE. Identity cannot be asked about a directory that is not there, so a
missing root drops the rung to a case-folded text comparison that does not fold Unicode
normalisation — It is no
longer one. Every composer now establishes the root before it asks: both local arms have
always done so on their shared body, and the container arm — which is a separate composer in a
separate file and simply never had the line — does so as of this round. The creation is one
idempotent MkdirAll on this engine's own state home, the same directory and the same 0700 the
door's own setup uses a moment later.
The window that has been closed was measured, in both directions, through the production composition path and not through the guard alone:
| the egress root, at the moment the container argv is composed | another run's directory, spelled as a twin of the root |
|---|---|
| absent (what shipped before this round) | ADMITTED |
| present (what ships now, unconditionally) | REFUSED |
A removed window beats a documented one. What remains true — and is worth knowing when reading the code — is that the lexical fallback still folds only case: it is now unreachable from any composer this engine has, rather than reachable and declared.
An earlier draft of this section claimed less and claimed it too broadly, and a security
review measured the difference. It said "mounting the egress directory is now refused by
name" — but the guard of that draft refused the root and its ancestors and admitted
everything under it. So <egress-root>/<another-run-id> — another concurrent run's live door,
the one object the per-run mint exists to keep private — was admitted, and a container given
it would have had its traffic judged under the victim's policy. The sentence "a table drives
the real guard over the population an exemption could plausibly leak to" overstated the table by
exactly the mirror the table was missing.
The control that proves it, restated as a number. The table drives the real guard over 35
hazardous shapes — every member of the three groups above, plus the door with no exemption
threaded, a path one byte off the door, a path that merely begins with the door, the Docker
socket, the Docker socket named as its own exemption, the Podman socket, a rootless Podman socket
directory and an unrelated .sock — and requires a refusal for 35 of 35. Exactly four
rows are admitted, and all four are spellings of one path: this run's own door, resolved and
unresolved. That admitted count is asserted too, because a table where every row expects a
refusal passes against a guard that refuses the engine's own door.
And the same 39 rows are run again with their spellings case-flipped: 130 twins, derived from
the one table by four transforms — the root's own last element upper-cased, the root in a third
case, the source's last component flipped (which is also how an upper-case .SOCK suffix is
covered), and one letter flipped mid-component. The twins are generated, never typed, so a row
added to the population is asserted five times. Their assertion is not "everything is refused",
because on a case-insensitive filesystem a flipped spelling of this run's own door is the same
inode and admitting it is right: it is that an admission is legal only when the source is the
same filesystem object as the exempted door, which is true on both platforms. Measured:
114 refused, 16 admitted, and all 16 are that one door.
⚠ One member of the population is a declared limit and not a guarded shape. A relative
mount source (source=run-B/d.sock) is read as a named volume, not a host path — Docker,
which is where this was measured, refuses a relative bind source outright — so it reaches nothing
under the egress root. A test asserts that behaviour so a change to it is visible, but it is
scope, not a proof. The runtimes this engine will drive are a closed set of exactly two
(docker, podman, refused at registry-write time); docker is measured and podman is owed,
and an earlier draft of this paragraph said "every runtime this engine drives" on one runtime's
evidence.
9A.7 What this door does not contain
Read this beside §9's gap list, not instead of it. The socket makes the control reachable. It does not make it mandatory — the network-layer work in §8's network-layer row is what would do that, and it has not started.
Three further limits, each measured rather than assumed:
- The fleet's own Go toolchain does not go through this door. Go's
net/httpdoes not readALL_PROXYat all, in any case or scheme. It readsHTTPS_PROXY— but handed a unix socket in that variable it takeslocalhost, defaults the port to 1080 and throws the path away, then reports aconnection refusedthat reads like a network outage. The engine therefore mintsALL_PROXYand deliberately does not mintHTTPS_PROXY: a name the client mis-parses is worse than one it never sees, because the first produces a wrong diagnosis on every run. ⇒curlandgit-over-HTTPS honour the door;go mod downloaddoes not. pipcannot speak SOCKS5 on a stock macOS install — it honours the variable and raisesMissing dependencies for SOCKS supportbefore opening any connection. Identical over a TCP listener, so it is a missing Python package and not the socket form.- The remote (ssh) lane has a far door on ONE arm, and still refuses by name on the others.
⚠ This bullet read "The remote (ssh) lane has no door at all … A far-side door is unwritten"
until 2026-08-30, and both halves are now out of date. The far side has its own door,
served on the far host by the far binary (
saphan egress-door); §8 rules the far door BUILT AND LIVE — reached by a far spawn that declares a policy, and by no other. **This clause said §8 rules it "BUILT, NOT WIRED" All three were invisible to every check in the product until a guard named that prose rule was written — it found this one, which two humans reading for exactly this defect had walked past. See §9B, which states in full the two axes on which a far spawn is still refused, and the residuals this lane has and the local one does not.
9A.8 Cleanup, and what happens if the engine dies
The socket is closed and unlinked by the same function that opened it, in a deferred call —
the one position no early return, no refusal and no panic can skip. Unlinking matters and is
not tidiness: closing the listener alone leaves the inode, and a client dialling it then gets
connection refused, which is the identical answer it gets from a door that has not come up
yet. An absent path is unambiguous.
The case a deferred call cannot cover is the engine being killed outright. That is handled from the other side: the next bind on a path reclaims a stale inode, and refuses to steal a live one — dialling tells the two apart, and two runs cannot legitimately share a path because it carries the run id. A collision is therefore a real collision and is reported rather than silently resolved by taking the socket away from whoever is serving it.