Saphan StudioDocs
Security

Advanced: the egress door inside the confinement boundary

This section is for a reviewer auditing the confinement itself.

This section is for a reviewer auditing the confinement itself. Section 8 describes the boundary a run gets; section 11 states that egress is unrestricted for a run that does not opt in, and names the proxy as the answer for one that does. What follows is the piece of that answer that lives in the boundary: the socket the proxy is reached through, and what had to be true of the boundary for such a socket to exist at all. It is written as a set of prices paid. Everything the door does not yet contain is in Egress §8 and §9, and this section does not soften any of it.

11A.1 What is in the boundary now

A run may declare an egress policy. When it does, the engine mints that run its own unix socket, in a directory it owns, outside every tree the confinement makes writable, and makes exactly that one path reachable from inside the boundary. If any part of that cannot be done, the run does not start. There is no configuration under which a run declares an egress policy and then runs with no door.

Read that last sentence exactly as narrow as it is, because an earlier draft of this document overstated it. It said "…and then runs with an unmanaged network", which contradicts §11A.6 four paragraphs below — and §11A.6 is the true one. What is guaranteed is that a declared policy either gets its door or gets a refusal. What is not guaranteed, and is not claimed anywhere in this release, is that traffic goes through that door: neither local arm creates a network namespace, so a run may ignore the socket and open its own connection. The run's own record says so in its own words — it reports a door as REACHABLE, never as enforced.

That is a change to what section 8's boundary contains — the first one that adds a reachable resource rather than a writable tree — and it is why the paragraph below about a widened guard belongs in this document rather than only in Egress.

11A.2 The placement, and the two alternatives that were rejected on measurements

A unix socket's path lives in a fixed C array of 103 usable bytes across both platforms this product confines on. Every short writable path on macOS is under the temp root; and on Linux the boundary hands the run a private /tmp. Those two facts do not both fit, so the placement had to be decided rather than defaulted:

  • A different path per platform was rejected. It buys compatibility by splitting the one property the control exists to give — one policy on two systems. A boundary whose reviewer has to audit two arrangements is two boundaries.
  • Removing the private /tmp was rejected on a measurement, and the first framing of it was wrong. The Linux runner's /tmp is an ext2/ext3 filesystem, not a tmpfs; a file created on the host's /tmp is nonetheless invisible inside the confined run and visible without the flag. ⇒ The flag is mount isolation, not filesystem type. Removing it would show every run the host's /tmp and each other's files — a channel between concurrent runs. That is a worse defect than the one it would fix.
  • A directory the proxy owns, outside both temp roots, was ratified. The price is one named exemption in a guard this product wrote itself (§11A.4), and that price is stated rather than absorbed.

11A.3 Why the socket is not in the run's own directory

Section 8's boundary binds the run directory writable — it must, because the transcript and the captures land there. An agent that can write it could unlink the socket and bind its own, and every subsequent line of the audit record would describe a proxy the agent supplied. A socket the agent can replace is a name, not an identity. So the door is minted outside every writable tree, the agent may connect to it and may do nothing else to it, and on both platforms it cannot delete it — on Linux because the socket is a mount point (EBUSY), on macOS because it is granted for reaching and reading and never for writing (seatbelt checks unlink against the target path, so a write grant would hand the power straight back).

11A.4 The container guard, widened by exactly one path

Section 8's container arm refuses any container argv that bind-mounts a unix socket, because a process holding a runtime's control socket can start a second container with any flags at all. The egress door is a unix socket and is the opposite object — the only way out of a --network none container, with everything through it judged.

The guard therefore has one exemption, under four conditions, all required: a door was minted for this run; the mount source is that door — the same filesystem object, or failing that the same path string; the path named in the exemption is under this engine's own egress root; and the path is not a container runtime's control socket, tested the same two ways every other source is. It widens the "this source is a .sock" test and nothing else — privileged mode, host networking, added capabilities, disabled seccomp or AppArmor, the host user namespace, the host root filesystem and the runtime sockets are judged exactly as before. The same change made the guard stricter in one place: everything under the engine's egress root is refused except this run's own door — the root itself, any path that contains it, another run's socket directory, another run's socket, any descendant at any depth, and each of those written with a trailing slash, with . or //, through .., or reached through a symlink.

Two sentences in the paragraph above are corrections, and both were measured by review rather than noticed. (1) The conditions were described as "unchanged in number and in meaning" across a round in which two of the four moved in meaning: the equality became an equality "in some spelling" and then an identity comparison, and condition 3 changed its subject from the mount source to the exemption. The number is genuinely unchanged; the meaning was not. (2) Condition 3 was described as the condition that stops a mis-composed run naming something else — which its new subject does not do: a caller threading an unresolved symlinked door whose target is outside the root gets that outside directory admitted. That does not open in the shipped composition, which resolves the door before handing it over, and it is not closed today.

**AND THE "every spelling" CLAIM WAS FALSE ON macOS ** The rung compared path TEXT, and on a case-insensitive filesystem — the macOS default — <EGRESS-ROOT>/<other-run-id> and <EGRESS-ROOT> are the same directories as their lower-case spellings. Both were admitted: the same hole this section says was closed, reachable through one flipped letter, on the platform whose run separation §11A.5 already marks ARGUED rather than STRUCTURAL. The repair is not a case-insensitive string comparison — this filesystem also ignores Unicode normalisation, so that would have fallen to the next spelling. The guard now asks the filesystem which object a path names (device and inode) for the source, for each of its ancestors and for the root, so every encoding the kernel resolves to one inode collapses before any comparison. The whole 39-row population is re-run against 130 generated case twins, and the property asserted is that an admission is legal only when the source is the same object as the exempted door.

Identity has a precondition, and the precondition is now established rather than assumed. The kernel cannot be asked which object a missing directory is, so with the egress root absent the rung falls back to a case-folded text comparison — which a Unicode-variant spelling walks past. That fallback was reachable in production Measured through the production path in both directions: root absent ⇒ a twin spelling of the root is ADMITTED; root present ⇒ REFUSED. The container composer now creates the root before it asks — one idempotent MkdirAll on this engine's own state home — so the fallback is unreachable from any composer this engine has. This was a documented limit; it is now a removed one, which is the stronger outcome and the reason the sentence declaring it is gone from Egress rather than softened.

An earlier draft of this paragraph said only "mounting the egress directory is now refused by name", and a security review measured that as false for a per-run directory. The guard of that draft refused the root and everything containing it and admitted everything under it — so <egress-root>/<another-run-id>, which is precisely the object the per-run door exists to keep private, passed. A container given it holds another concurrent run's live door, and that run's proxy would then judge the container's traffic under the victim's policy. The repair refuses the whole neighbourhood in both directions and composes the exemption with the descendant test rather than after it.

Ask for the control, not the claim. The exemption is proved narrow by a table that drives the real guard over 35 hazardous shapes and requires a refusal for every one: the root, its parent, the host root, another run's directory and socket, a deeper descendant, a descendant that does not exist yet, a directory whose name ends .sock, this run's own socket directory, the .././///trailing-slash spellings of those, four symlinks whose targets are members of the set, a path one byte off the door, a path that merely begins with the door, the Docker socket, the Podman socket, a rootless Podman socket directory and an unrelated .sock. Exactly four rows are admitted, and all four are spellings of one path — this run's own door, resolved and unresolved — because a table where every row expects a refusal would pass against a guard that refuses the engine's own door too.

One member of that population is a named limit rather than a guarded shape: a relative mount source (source=run-B/d.sock). Every runtime this engine drives reads a relative source as a named volume and not as a host path — Docker refuses a relative bind source outright — so it reaches no directory under the egress root. That behaviour is asserted by a test so a future change to it is visible, but it is declared scope, not a proof about every runtime.

11A.5 Separation between concurrent runs, and the one check that is owed

Two runs on one host run as the same operating-system user, so file modes do not separate them and are not claimed to. The separation is the confinement's:

  • Linux — STRUCTURAL. The egress root is replaced by an empty tmpfs in each run's mount namespace before that run's own socket is bound back over it. Another run's socket does not exist in this run's view. There is no rule to evaluate and nothing to get wrong at evaluation time: the path is absent.
  • macOS — ARGUED, and weaker than the line above. The egress root is denied for network-outbound and file-read*, and exactly one literal path is allowed back, in that order, because seatbelt is last-match-wins. This is a RULE, not an absence — the run can still name the other socket, and the separation depends on the kernel evaluating that rule the way the profile intends.

The macOS clause is ARGUED. Read the three parts separately, because they are not equally strong, and the difference is the point of this block:

  1. Last-match-wins IS proven on this product, and it is not an assumption. The existing darwin confinement already depends on it: the profile emits a blanket (deny file-write*) and then a later (allow file-write* (subpath <workdir>)), and a test drives a real sandbox-exec and fails if the workdir write is refused. Under first-match-wins no confined macOS run could write its own workdir. The ordering semantics are live-kernel-verified.
  2. What is NOT measured is the network-outbound half, and it is the load-bearing half: that seatbelt evaluates connect(2) on an AF_UNIX path as network-outbound with a path filter, and that (deny network-outbound (subpath <dir>)) blocks a connect(2) to a socket beneath that directory. The (allow network-outbound (literal <path>)) idiom is real and is what Apple's own profiles use, but this product has not measured it against a kernel.
  3. The measurement that would settle it, and why it cannot be taken here. It is a live sandbox-exec run of the composed profile, with one process bound to a socket under the root and a second attempting connect(2) to it — expecting EPERM for 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 occurrences, both by the engineering leg and independently by its reviewer), and every leg that touches this code runs inside one. ⛔ Disabling that confinement to obtain the number is not an acceptable way to get it. The measurement is owed from an unconfined macOS host.

⇒ A reviewer asking "is this enforced on macOS?" must be told: the profile is composed correctly under a rule this product already relies on and live-tests elsewhere; whether the kernel applies that rule to an AF_UNIX connect is asserted and owed. Nothing equivalent is outstanding on Linux.

11A.6 What this does not change about section 11

  • the fleet's own Go toolchain does not use the door (Go's HTTP client does not read ALL_PROXY, and cannot parse a unix socket given in HTTPS_PROXY — it silently dials [::1]:1080 instead, so the engine deliberately does not set that variable);
  • pip on a stock macOS install cannot speak SOCKS5 at all (a missing Python package, not the socket form — identical over a TCP listener);
  • the remote (ssh) lane has a far door of its own on one shape of far host, and refuses by name on the others. ⚠ A far spawn declaring a policy is governed where the far host's rows measure seatbelt and seatbelt-network-deny and this engine jails that backend on that platform, and refused by name on a landlock host, on an unmeasured row, and on a backend outside this engine's jail scope for that platform (§9B.3). ⇒ Coverage on this lane is therefore per far host and measured, never fleet-wide, and a refusal is a refusal by name rather than an ungoverned dispatch.