9B. The remote (ssh) lane: a door on the far host, or a refusal
Everything in §9A describes a door served by the engine on the dispatcher, for a child on the same machine.
Everything in §9A describes a door served by the engine on the dispatcher, for a child on the same machine. A leg dispatched over ssh runs on somebody else's computer, and a unix socket path is not a transport — which is why, That refusal was correct on its own terms and it is gone, because the thing it was protecting against was built: the far host now serves the far door, out of its own copy of this engine's binary. §8 rules that mechanism BUILT, NOT WIRED — it ships, it is tested, and no production run reaches it, because nothing assigns a run an egress policy.
9B.1 What is composed, and in what order
The far command is nested, and the nesting is the security property:
- The door is OUTSIDE the jail. It is a listener, so something has to keep accepting while
the agent runs:
egress-doorstays alive as the agent's parent, and the two lifetimes are nested by construction. A door composed inside the domain would be a listener the confined agent's own boundary applies to. - The policy TRAVELS as bytes. A far host may be assumed to carry source code and nothing else — no workspace, no policy tree, no file anybody may assume is there — so the document itself rides on the argv with this engine's sha256 beside it. If the wire mangles it, the far load refuses by name rather than judging the run by bytes nobody addressed.
- The wall travels as a RULE, not as a profile.
egress_dooris a declared field in the same policy JSON the filesystem boundary already travels in, for the same reasonagent_scratchis: the record shows the rule, and the far side either expresses it or refuses it.
9B.2 The lifetime, including the ugly paths
| what happens | what the far host is left with |
|---|---|
| the agent exits (any status) | the door closes, drains conversations in flight, unlinks the socket, removes .saphan-egress/, and exits with the agent's own status |
| the ssh session drops / the operator interrupts | INT and TERM are forwarded to the agent, and the door closes behind it — same teardown |
the far egress-door process is killed outright | the socket inode is left behind and nothing serves it: the leg's egress fails closed. The next door on that path reclaims the stale inode after proving it is not live, and refuses to steal a live one |
⛔ There is no daemon mode, no detach and no pidfile — deliberately. A proxy left running on somebody else's host is a security defect, so the verb has no way to produce one.
9B.3 The arms that are still refused, and what to do about each
AXIS 1 — the far host's confinement row:
| the far host's confinement row | what happens to a spawn declaring an egress policy |
|---|---|
seatbelt (a Darwin host) and an egress row measuring seatbelt-network-deny | GOVERNED — provided this engine jails the spawn's BACKEND on that platform (axis 2). The profile denies network* and re-opens exactly one literal — this run's door |
seatbelt with the egress axis unmeasured | REFUSED. Two rows, two measurements; an unmeasured axis is a hole, never a no. ⇒ saphan machine probe-confinement <machine-id> --probed-by <actor> --workspace <root>, from a shell that is not itself confined |
landlock (a Linux host) | REFUSED, and re-probing changes nothing. The far entry point is saphan confine-exec, which applies a filesystem domain and execs; it emits no --unshare-net (bubblewrap is a dispatcher-side tool and is not on this lane at all) and the product implements no network right at any ABI. The host may well have netns — the missing rung is this engine's |
unprobed / unavailable | REFUSED by the confinement gate, before egress is even asked |
AXIS 2 — the spawn's backend, against the platform that row resolves to. Some backend rows
are scoped: this engine jails them on named platforms and nowhere else
(backendTraits.confinableOnlyOn; today exactly one row — qwen-code, scoped to linux).
The far platform is read from the machine's measured confinement row (seatbelt ⇒ darwin,
landlock ⇒ linux) and never from the dispatcher's own OS.
| the spawn's backend, on the platform the far row resolves to | what happens to a spawn declaring an egress policy |
|---|---|
| jailed on that platform (every backend row without a scope, on either platform) | GOVERNED, subject to axis 1 |
jailed by this engine, but not on that platform — e.g. qwen-code on a seatbelt (Darwin) host | REFUSED (confinement-unavailable, naming backendConfinableOn). Without an egress policy that spawn still RUNS, ungoverned and recorded as such — the platform arm is a scope statement, not a gap. With a policy it cannot run: no saphan confine-exec wrapper is composed, therefore no wall, therefore the door would be a hole in nothing and the record would read egress: WALLED over a bare agent argv |
jailed on no transport at all (e.g. codex, which confines itself) | REFUSED (confinement-unavailable, naming backendConfinable) — same reason, one axis up |
Your act, when you are holding the axis-2 refusal — three, and only the third is an owner's
decision: route the leg to a machine whose measured platform is one this engine jails that
backend on; run that backend on a lane it is confined on; or measure and land that platform's
confinement for that backend, which edits backendTraits.confinableOnlyOn and is a posture
change, not a dispatch one.
Do not read a capable machine row as an answer to this question. That machine's
confinement and egress rows may both read capable and this refusal still correctly stands —
it is a fact about the backend, and saphan machine probe-confinement will not move it.
Your act, when you are holding the Linux refusal — three, in the order of what they cost:
route the leg to a machine whose row reads seatbelt; run it on the local transport, where the
netns rung is composed; or land a far Linux cut (an unprivileged network namespace entered by the
door verb, or a Landlock ABI-4 network ruleset). The first two are dispatch decisions. The third
is the owner's, and nothing in this engine will quietly substitute for it.
WHAT THE THIRD ACT WOULD REQUIRE OF THE HOST — read this before you read the refusal as a statement about Linux. The refusal above is about this engine's composer; the kernel side is already there, on every host this fleet dispatches to:
- the TCP cut needs Landlock ABI ≥ 4 — where
LANDLOCK_ACCESS_NET_*first appears — OR an unprivileged network namespace. Measured 2026-08-30: ABI ≥ 4 on 6 of 6 Linux hosts in this fleet (runner-saphanandrunner-codexat ABI 4;runner-oci-1,builder-amd64-1andrunner-androidat ABI 6;strix-qwenat ABI 8), and onrunner-saphanunshare --user --netsucceeds with 0 interfaces UP inside it. Neither requirement is what stands in the way. - scoping an abstract unix socket — so a walled agent cannot reach a listener through the
abstract namespace — needs Landlock ABI ≥ 6 (
LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET). That is a real host requirement and two of the six hosts above do not meet it. - ⛔ Landlock governs TCP; UDP — and therefore DNS — stays open. A run walled this way can still resolve names and still speak any UDP protocol, so the wall is narrower than "the network" and must be described as what it is.
WHERE THOSE ABI NUMBERS COME FROM, because a number without a provenance behaves like a
number this document measured. They are the master's reading, dated 2026-08-30, taken with
python3+ctypes on syscall 444 — go exists on only one of the six hosts — and they were
NOT re-run from the worktree that published them, which has no ssh grant. So they are carried
as one actor's measurement and labelled as one, here and in the product,
which is the only other place they are written down. ⇒ A reader who needs them fresh re-probes;
a reader who needs them attributed has the attribution.
⚠ Two cautions on those numbers, because a reading without them invites the wrong conclusion.
runner-oci-1 carries no landlock.h — that is a missing instrument, not a missing
feature, and its constants are read from no header rather than inferred from its kernel version.
And a met host requirement is not a plan: this engine composes no Linux network lever today,
this section does not say it will, and the work is a rung to be composed, probed and
negative-controlled per host like every other rung on the ladder.
9B.4 Rollout: the far binary must be new enough, and it says so loudly
confine-exec decodes its policy with DisallowUnknownFields, so a far host running an engine
older than this change refuses by name any policy carrying egress_door. That direction is
the right one — fail-closed and loud beats a boundary weaker than the record claims — and it is
also the cost: a governed remote dispatch needs the far host's saphan updated first.
Nothing else moves. A policy for a spawn that declares no egress policy is byte-identical to what it always was, on both arms, which is every remote dispatch this fleet has ever made — the spawn that would have carried one was refused outright until now.
9B.5 Two residuals this lane has and the local one does not
-
The socket is inside the agent's own writable root. §9A.3 keeps the local door out of every tree the confinement makes writable, because a door the child can unlink or replace is not an identity. On the far lane the workdir is the only directory a dispatch owns on that host — the far
$HOMEis a layout this engine never resolves, and a path it cannot resolve is a path it cannot check against the 103-byte ceiling before paying for the dial. So a hostile leg over there can unlink its own door. What that buys it is nothing: the wall re-opens exactly one literal path, so a proxy of its own cannot dial out either. -
The far binary is not digest-pinned.
saphanis resolved by bare name off$HOME/.saphan/bin:$PATHon that host — the same caveat the far confinement note has always carried about the binary that applies the jail, now true of the binary that serves the door as well. The agent it stands in front of is pinned; the engine binary is not. -
git statuson the far leg sees.saphan-egress/while the run is in flight. It is inside the worktree, exactly as.saphan-tmp/,.saphan-gocache/and.saphan-gomodcache/already are, and for the same reason: this engine aims what must exist inside the boundary rather than widening the boundary to reach it. The directory is removed when the run ends, so a return fetched afterwards never carries it — but a leg that runsgit add -Amid-run can stage it.
The first two are written into the run's own record, not only here, so a reader of the record is told rather than left to derive it from where the path points.