Keeping every descendant of a sandboxed XPC service’s worker inside its lifetime (fork, double-fork, setsid)

Apple DTS case 22893354: forum post and reply

Apple asked for the question as a Developer Forums post, then a reply to the
case email with the post URL. Nothing below has been posted or sent.

1. Forum post

Post at https://developer.apple.com/forums/ (signed in, "Ask a question").

Suggested tags: XPC, App Sandbox, Endpoint Security

Attach: fixtures/egress_guard/hp04/apple-descendant-sample/main.c, renamed
to main.txt if the forum refuses .c.

Title

Keeping every descendant of a sandboxed XPC service's worker inside its lifetime (fork, double-fork, setsid)

Body

We are designing a macOS app that runs an unmodified third-party command-line tool for one task at a time. The plan is an app-bundled, sandboxed XPC service that launches the tool as a child process. The tool is not ours, so we have to assume it may fork without exec, double-fork, and call setsid().

For each run we need one guarantee: after the run ends, every process the tool created has stopped. "Ends" includes the XPC service crashing and the host app crashing, not only a clean cancel. If we cannot prove that, we cannot report the run as finished.

What we have looked at so far:

  • Killing the process group. A child that calls setsid() is no longer in the group.
  • launchd's process-group cleanup for the job. As we read launchd.plist(5), that also covers the process group only.
  • Tracking pids from fork and exit notifications, including Endpoint Security. Notifications can be dropped, and pids can be reused, so a list rebuilt from events is not proof that nothing is left.

The attached sample shows the process shape without any sandbox or XPC. It starts a worker, the worker double-forks, and the grandchild calls setsid() and keeps writing to a log after the original process has exited. On macOS 15.6.1 the descendant shows parent pid 1 and its own session, and it keeps writing until it finishes on its own:

// worker: fork twice, let the intermediate processes exit
if (fork() > 0) _exit(0);
if (fork() > 0) _exit(0);
setsid();                 // leaves the original process group and session
for (int i = 0; i < 8; i++) {
    // append "tick i pid ppid" to the log, ppid is now 1
    usleep(200000);
}

Build with cc -Wall -Wextra -Werror -o DescendantOutlivesParent main.c and run ./DescendantOutlivesParent /tmp/descendant-log /tmp/descendant-ready.

Questions:

  1. Is there a supported mechanism that ties every descendant of a process started by an XPC service, including ones that called setsid(), to the lifetime of that service, so that they are terminated when the service exits or crashes?
  2. If the service is sandboxed, does sandbox inheritance (children inherit the parent's sandbox) also give us a way to find or terminate those descendants, or does it only restrict what they can do?
  3. If neither, what does Apple recommend for running an untrusted command-line tool where leftover descendants must be ruled out? For example, is a separate launchd job per run, a different process-management API, or Endpoint Security authorization the intended route?
  4. Can that mechanism be combined with per-run file access (one input folder and one scratch folder per run), without giving the tool a shared container or network access?

We would rather learn that a guarantee is not available than build on an assumption, so a "this is not supported" answer is also useful to us.

2. Reply to the DTS email

Reply to Apple's case email (keep the subject) after the post is live:

Case-ID: 22893354

Hello,

Thank you. I posted the question on the Developer Forums here:
<FORUM POST URL>

The sample project is attached to the post. There is nothing private in it.

Best regards,
<NAME>

Answered by DTS Engineer in 912530022

See my response here.
__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

See my response here.
__
Kevin Elliott
DTS Engineer, CoreOS/Hardware