Found in review of #73 (chatgpt-codex-connector, P2). Documented there as a known limit
(crates/honmoon-cli/src/isolate/macos.rs module header, README, wiki, TD-003) rather than fixed —
this issue tracks the fix.
The gap
honmoon run returns as soon as its direct child exits: Command::status() waits for that one
process, not for the tree. A command that daemonizes leaves descendants behind, and on macOS those
descendants keep the Seatbelt profile — it is kernel state a process carries, not a lineage it can
step out of.
The profile's one TCP exception is localhost:<proxy_port>. When run exits it closes both
loopback listeners, so that ephemeral port is free — while a surviving descendant still holds the
exception, and can read the port number out of its own proxy environment. Whichever local process
binds the freed port next becomes an off-policy relay for it.
The existing test a_descendant_that_outlives_its_parent_is_confined_too passes because the port is
dead, not because it is guarded.
Why Linux does not have it
There the child is in an empty network namespace reached over a bridged Unix socket. When honmoon
exits the bridge dies, and nothing off-box is reachable from inside that namespace no matter who
else is on the host. Seatbelt leaves the child on the host loopback, so the boundary lasts
exactly as long as honmoon owns a host resource. The asymmetry is structural, not an oversight in
the macOS path.
What it takes to exploit
A command that daemonizes, plus a second local process able to bind that specific ephemeral port in
the window after run exits. Narrow — but it is a hole rather than a documented escape: the
operator did not choose it the way they choose to expose /var/run/docker.sock.
Candidate fix
Hold the listeners for the sandbox's lifetime rather than the direct child's:
- put the child in its own process group (
std::os::unix::process::CommandExt::process_group);
- keep the sockets bound while
kill(-pgid, 0) still succeeds;
- propagate the direct child's exit code as today.
Why it did not land in #73
It changes what run is. It would no longer return when the command it was given returns, so
honmoon run -- <something that spawns a daemon> would block. That is a product decision — probably
the right one for an isolation tool, plausibly with a timeout or an opt-out flag — and it wants an
ADR-0005 amendment rather than a late commit in a review round.
It is also a narrowing, not a closure: a descendant that calls setsid() leaves the process group
and is unaffected.
The alternative, and why it has no form
codex's second suggestion was to "avoid leaving a reusable host-loopback exception behind". Under
sandbox-exec there is nothing to reach for: the profile's remote ip filter accepts only * or
localhost as a host, a Unix-socket proxy would not tighten anything (the profile already allows
filesystem sockets — a documented escape), and nothing survives process exit to hold the port.
Definition of done
References
Found in review of #73 (chatgpt-codex-connector, P2). Documented there as a known limit
(
crates/honmoon-cli/src/isolate/macos.rsmodule header, README, wiki, TD-003) rather than fixed —this issue tracks the fix.
The gap
honmoon runreturns as soon as its direct child exits:Command::status()waits for that oneprocess, not for the tree. A command that daemonizes leaves descendants behind, and on macOS those
descendants keep the Seatbelt profile — it is kernel state a process carries, not a lineage it can
step out of.
The profile's one TCP exception is
localhost:<proxy_port>. Whenrunexits it closes bothloopback listeners, so that ephemeral port is free — while a surviving descendant still holds the
exception, and can read the port number out of its own proxy environment. Whichever local process
binds the freed port next becomes an off-policy relay for it.
The existing test
a_descendant_that_outlives_its_parent_is_confined_toopasses because the port isdead, not because it is guarded.
Why Linux does not have it
There the child is in an empty network namespace reached over a bridged Unix socket. When
honmoonexits the bridge dies, and nothing off-box is reachable from inside that namespace no matter who
else is on the host. Seatbelt leaves the child on the host loopback, so the boundary lasts
exactly as long as honmoon owns a host resource. The asymmetry is structural, not an oversight in
the macOS path.
What it takes to exploit
A command that daemonizes, plus a second local process able to bind that specific ephemeral port in
the window after
runexits. Narrow — but it is a hole rather than a documented escape: theoperator did not choose it the way they choose to expose
/var/run/docker.sock.Candidate fix
Hold the listeners for the sandbox's lifetime rather than the direct child's:
std::os::unix::process::CommandExt::process_group);kill(-pgid, 0)still succeeds;Why it did not land in #73
It changes what
runis. It would no longer return when the command it was given returns, sohonmoon run -- <something that spawns a daemon>would block. That is a product decision — probablythe right one for an isolation tool, plausibly with a timeout or an opt-out flag — and it wants an
ADR-0005 amendment rather than a late commit in a review round.
It is also a narrowing, not a closure: a descendant that calls
setsid()leaves the process groupand is unaffected.
The alternative, and why it has no form
codex's second suggestion was to "avoid leaving a reusable host-loopback exception behind". Under
sandbox-execthere is nothing to reach for: the profile'sremote ipfilter accepts only*orlocalhostas a host, a Unix-socket proxy would not tighten anything (the profile already allowsfilesystem sockets — a documented escape), and nothing survives process exit to hold the port.
Definition of done
runguarantees, and what happens to a daemonizing commandthe current descendant test does not distinguish "guarded" from "dead"
setsid()escape stated in the module header as the residual limitReferences
honmoon runisolation with a Seatbelt profile on macOS (ADR-0005) #73 — the review thread and the documented limit.please/docs/decisions/0005-empty-namespace-and-bridged-proxy-sockets.md.please/docs/tracks/tech-debt-tracker.md(TD-003)