Skip to content

macOS: a daemonized descendant outlives the proxy port its Seatbelt profile still allows #74

Description

@amondnet

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

  • ADR-0005 amended with the lifetime run guarantees, and what happens to a daemonizing command
  • Process-group hold implemented behind whatever the ADR decides (default, flag, or timeout)
  • A test that binds the freed port from another process and asserts the orphan cannot reach it —
    the current descendant test does not distinguish "guarded" from "dead"
  • setsid() escape stated in the module header as the residual limit
  • TD-003's fourth bullet updated or removed

References

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    type:bugSomething isn't working

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions