macOS: supported process-tree lifetime boundary after supervisor loss

I am building Ouroboros (ouro-jail), a contained developer workload runner. Before advertising native macOS support, I need a supported lifetime boundary that covers ordinary fork/spawn/exec descendants, including detached sessions and double-fork daemons. Execution must stay contained when every userspace custodian dies, rather than relying on a surviving process to enumerate and kill descendants.

Does macOS provide a supported facility that can:

  1. Establish an immutable workload scope before its first instruction and atomically inherit membership through fork/spawn/exec, without modifying the workload or preventing ordinary Node, shell and Git subprocess behavior?
  2. Stop or terminate every member after loss of the final control owner, with enforcement retained in the kernel and no userspace callback required?
  3. Handle concurrent creation during teardown and expose a verifiable final drain, using stable process identities rather than PID-only scans?

Development evidence on macOS 27.0 build 26A428, XNU 13432.1.9 (isolated VM, SIP already disabled in the guest; this is development evidence, not normal-SIP qualification):

  • Descendant-scoped Endpoint Security clients observe the tested subtree. Reciprocal custodians handle either client's loss, but killing both leaves all known members running. Uncached fail-closed AUTH_EXEC lets a survivor execute a new command after both clients disappear (200 reciprocal-custody trials).
  • PT_ATTACHEXC gives kernel-enforced debugger-death cleanup for attached targets, but later fork/spawn children survive (80 debugger-lifetime trials).
  • A locally installed, identity-protected Mach exception handler is inherited through the tested fork/spawn/exec paths. Owner death changes it to a dead handler without stopping normal execution; a child can clear it. Breakpoint controls confirm real kernel exception delivery (24 Mach-lifetime trials).
  • Published XNU contains per-task fatal-port no-senders cleanup, but machdep.task_get_fatal_port is absent on this tested release kernel. I have not treated it as a supported application API.
  • LaunchServices creates a real application coalition, and XPC service lifetime follows app death. However, all seven workload descendants survive, retain coalition membership and can execute a new command. Shared and private audit sessions both fail; eight-second follow-ups also retain all seven survivors. A temporary root launchd service restarts and reaps seven recorded processes after a crash, but does not restart a stopped custodian (native-service trials).

I understand Endpoint Security entitlement is separate from a lifetime guarantee. Please clarify whether an approved descendant-scoped ES deployment can provide that owner-loss guarantee through a documented API, whether another supported facility is appropriate, or whether meeting this contract requires a VM boundary.

Below are the relevant parts of a focused C command-line reproduction (192 lines total, Xcode 27.0, SDK 27.0, -Wall -Wextra -Werror, no signing or third-party libraries). The code ran in the isolated VM; the packaged Xcode project has only been build-checked.

1. The owner installs a protected exception handler on itself, then forks the workload. No cleanup callback follows.

static const exception_mask_t masks = EXC_MASK_SYSCALL | EXC_MASK_MACH_SYSCALL | EXC_MASK_BREAKPOINT;
static const exception_behavior_t behavior = EXCEPTION_IDENTITY_PROTECTED | MACH_EXCEPTION_CODES;

mach_port_t channel = MACH_PORT_NULL;
mach_port_options_t options = {.flags = MPO_EXCEPTION_PORT | MPO_INSERT_SEND_RIGHT};
kern_return_t rc = mach_port_construct(mach_task_self(), &options, 0, &channel);
if (rc != KERN_SUCCESS) return 4;
rc = task_set_exception_ports(mach_task_self(), masks, channel, behavior, THREAD_STATE_NONE);
if (rc != KERN_SUCCESS) return 4;
pid_t child = fork();
if (!child) {
    /* Ordinary receive rights are not inherited. Only the kernel's task
     * exception-handler state provides supervision of this child. */
    _exit(payload(directory, mode, exe));
}

2. The workload detaches (double-fork shown; fork, setsid and posix_spawn SETPGROUP/SETSID also tested; a child can also clear the handler with task_set_exception_ports(..., MACH_PORT_NULL, ...)).

child = fork();
if (!child) {
    if (strcmp(mode, "fork") && setsid() < 0) _exit(4);
    if (!strcmp(mode, "double-fork")) {
        pid_t second = fork(); if (second < 0) _exit(4); if (second) _exit(0);
    }
    child_work(directory, mode);
}

3. Each member reads its inherited handler state and keeps running ordinary BSD syscalls and a Mach trap.

kern_return_t rc = task_get_exception_ports(mach_task_self(), masks, observed, &count,
                                            ports, behaviors, flavors);
/* port == MACH_PORT_DEAD -> dead_mask, valid send right -> live_mask */

for (;;) {
    if (write(fd, ".", 1) != 1) _exit(3);   /* heartbeat file */
    (void)getpid();
    mach_port_t thread = mach_thread_self();
    mach_port_deallocate(mach_task_self(), thread);
    usleep(10000);
}

Observed output (double-fork case, original VM run). Workload, before and after SIGKILL of the owner (pid 1472):

{"kind":"exception_state","role":"root","phase":"before_owner_loss","rc":0,"live_mask":448,"dead_mask":0}
{"kind":"exception_state","role":"late-child","phase":"before_owner_loss","rc":0,"live_mask":448,"dead_mask":0}
{"kind":"exception_state","role":"root","phase":"after_owner_loss","rc":0,"live_mask":0,"dead_mask":448}
{"kind":"exception_state","role":"late-child","phase":"after_owner_loss","rc":0,"live_mask":0,"dead_mask":448}

After owner loss, both root (pid 1473) and the detached late-child (pid 1475, own session) were still alive under the same uniqueid/pidversion, reparented to launchd, with heartbeat files still growing. Across 21 owner-death trials in 7 modes, 39 known fixture processes survived and no case left the fixture empty. The 3 breakpoint controls (owner alive) confirmed real exception delivery.

Expected contract: a documented kernel-owned boundary prevents continued execution by every inherited member after all control owners disappear, without enumerating descendants after the fact or requiring workload cooperation. I am asking which supported macOS API meets that contract, not asserting that exception ports promise it.

Apple DTS asked me to start this thread (Case-ID: 22799543) and will review once I reply with the link.

The full reproduction steps and environment details are in the attached text.

Expected contract: a documented kernel-owned boundary prevents continued execution by every inherited member after all control owners disappear, without enumerating descendants after the fact or requiring workload cooperation.

No, nothing like this currently exists. What is possible is:

  • An es_new_descendants_client can defend itself from all child activity. External events may still terminate it, but its children cannot.

  • A "full" client (using es_new_client) can defend itself from ALL activity system-wide.

The main risk/downside here is bugs in the client causing the client to terminate, but the best solution to that is to minimize the code involved so that it's as simple as possible to test and validate. To that end, I would probably start with a basic two-level architecture like this:

  1. The first process’s ONLY job is to monitor child process activity and defend itself and the secondary client from manipulation from children. It sets all of that up using es_new_descendants_client, then spawns the second process.

  2. The second process is responsible for all other monitoring that occurs and is where the bulk of the work actually occurs. It sets up its own es_new_descendants_client, then spawns the "work" that you'll actually be monitoring.

  3. (optional) If you're concerned about system-wide attacks, then I think I would introduce a 3rd process which uses the full es_client to defend itself and the other two processes.

That 3rd component can also be embedded in either/both of 1 & 2.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

macOS: supported process-tree lifetime boundary after supervisor loss
 
 
Q