Skip to content

Seatbelt: detached descendants outlive kill/wait/dispose; clarify cross-session cleanup #1459

Open

Description

@SatyKrish

Relevant area

macOS

Brief description

In a reduced synthetic reproduction through the public SDK, a child and grandchild created with Node's
spawn(..., { detached: true, stdio: 'ignore' }) remained alive after MXC
timeout, explicit MxcProcess.kill(), and normal workload exit. The caller
awaited wait() and called dispose() before checking survival. Ordinary
descendants were cleaned up in each corresponding control.

This concerns workload termination, not a demonstrated sandbox access bypass.
The surviving grandchild still received EPERM when reading a planted denied
canary and writing a synthetic read-only input. It continued writing only an
allowed heartbeat. The test supervisor removed its own descendants afterward.

Observed on 9 October 2026:

  • macOS 26.5.1, Apple Silicon arm64
  • Node.js 25.8.2; npm 11.11.1
  • Official @microsoft/mxc-sdk@1.0.0, entrypoint @microsoft/mxc-sdk/v1
  • Seatbelt, pipes, nestedPty: false, GUI/keychain disabled
  • Default-deny network, selected read-only paths, one synthetic writable directory

Steps to reproduce

The complete repro.mjs and package manifest are included below. All three
modes were executed once with detached descendants and once with ordinary
descendants on the platform above. The script's descendants self-exit after
five seconds; the host also signals only recorded PIDs carrying its unique
test nonce. Every invocation verified zero live descendants after host cleanup
and removed its synthetic temporary directory.

In an isolated directory on a supported Mac:

npm install --ignore-scripts --no-audit --no-fund
node repro.mjs cancel detached
node repro.mjs cancel ordinary

The same script accepts timeout or normal-exit instead of cancel. Each
invocation creates only temporary synthetic files and two descendants. Run it
as the ordinary user in an environment that permits native Seatbelt setup.

The caller used this sequence (kill() and dispose() are synchronous;
only wait() is awaited):

const child = await spawn(request, { telemetry: { enabled: false } });
// Attach output listeners and close stdin before waiting.
// Cancellation calls child.kill(); timeout uses request.timeoutMs.
try {
  const outcome = await child.wait();
} finally {
  child.dispose();
}

Expected behavior / contract question

Our application requires all job descendants to terminate at timeout or
cancellation. Is that guaranteed by Seatbelt kill()/timeout plus
wait()/dispose(), including descendants that create a new Unix session?
Should normal leader exit also terminate those descendants, or is that caller
responsibility? If this is intentionally outside the API contract, please
document the cleanup scope and the supported caller supervision approach.

The Seatbelt documentation
describes process-tree isolation and no named-container lifecycle, but does not
explicitly resolve detached-descendant termination. We are seeking contract
clarification before classifying these observations as API defects.

Actual behavior

The reduced reproduction observed descendants 100 ms after wait()/dispose() and
checked heartbeat growth over the next 100 ms:

Mode Wait outcome Surviving descendants Heartbeat bytes
Detached timeout (650 ms) exitCode: -1, timedOut: true 2 15 → 17
Detached explicit kill exitCode: -1, timedOut: false 2 6 → 9
Detached normal exit exitCode: 0, timedOut: false 2 4 → 6
Ordinary timeout exitCode: -1, timedOut: true 0 12 → 12
Ordinary explicit kill exitCode: -1, timedOut: false 0 4 → 4
Ordinary normal exit exitCode: 0, timedOut: false 0 2 → 2

MXC warnings and stderr were empty in all six cases. The normal-exit leader
waited 150 ms before exiting; explicit kill occurred 250 ms after initial
stdout. Both descendants had to start before a result was interpreted.
In each case the synthetic denied-read and read-only-input-write probes
returned EPERM; no policy access escape was observed in those checks.

Source observations

The pinned Seatbelt runner
scans the original Unix session, covering multiple process groups in that
session. Node's detached option
creates a new session on Unix. This appears to explain the difference.

The pinned Node settling implementation
awaits native handle release before resolving wait(). The caller did not
omit disposal. We have not tested a different MXC API or a later release.

Complete reproduction

package.json:

{
  "name": "mxc-seatbelt-detached-reproduction",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": { "node": ">=24" },
  "dependencies": { "@microsoft/mxc-sdk": "1.0.0" }
}
repro.mjs
import { spawn as mxcSpawn } from '@microsoft/mxc-sdk/v1';
import { execFileSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';

// One invocation creates only synthetic temporary files and two descendants.
// Descendants also self-exit after five seconds if host cleanup is interrupted.
const mode = process.argv[2] ?? 'cancel';
const detached = (process.argv[3] ?? 'detached') === 'detached';
if (!['cancel', 'timeout', 'normal-exit'].includes(mode)
    || !['detached', 'ordinary'].includes(process.argv[3] ?? 'detached')) {
  throw new Error('Usage: node repro.mjs [cancel|timeout|normal-exit] [detached|ordinary]');
}
if (process.platform !== 'darwin') throw new Error('This reproduction targets macOS Seatbelt.');
const node = fs.realpathSync(process.execPath);
const scratch = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), 'mxc-detached-')));
const work = join(scratch, 'work');
const nonce = `mxc-synthetic-${randomUUID()}`;
fs.mkdirSync(work, { mode: 0o700 });
for (const name of ['home', 'tmp']) fs.mkdirSync(join(work, name), { mode: 0o700 });
const input = join(scratch, 'readonly-input.txt');
const canary = join(scratch, 'denied-canary.txt');
fs.writeFileSync(input, 'Synthetic protected input\n');
fs.writeFileSync(canary, 'Synthetic denied canary\n');
const config = { mode, detached, nonce, input, canary,
  pids: join(work, 'pids.json'), heartbeat: join(work, 'heartbeat'), scope: join(work, 'scope.json') };
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
const quote = value => `'${value.replaceAll("'", "'\\''")}'`;

function runtimeReadPaths() {
  const paths = new Set([node]);
  const queue = [node];
  const lib = join(dirname(dirname(node)), 'lib');
  while (queue.length) {
    const file = queue.shift();
    const lines = execFileSync('/usr/bin/otool', ['-L', file], { encoding: 'utf8', timeout: 2000 }).split('\n').slice(1);
    for (const line of lines) {
      let path = line.trim().split(' (')[0];
      if (path.startsWith('@loader_path/')) path = join(dirname(file), path.slice(13));
      if (path.startsWith('@rpath/')) {
        const name = path.slice(7);
        path = [join(dirname(file), name), join(lib, name)].find(fs.existsSync) ?? path;
      }
      if (!path.startsWith('/') || path.startsWith('/usr/lib/') || path.startsWith('/System/')
          || !fs.existsSync(path)) continue;
      path = fs.realpathSync(path);
      if (!paths.has(path)) { paths.add(path); queue.push(path); }
    }
  }
  // Homebrew Node/OpenSSL may read this config at startup; grant the file only.
  for (const path of ['/opt/homebrew/etc/openssl@3/openssl.cnf', '/usr/local/etc/openssl@3/openssl.cnf']) {
    if (fs.existsSync(path)) paths.add(fs.realpathSync(path));
  }
  return [...paths];
}

const grandchildCode = `
const fs = require('node:fs');
const c = JSON.parse(process.argv[1]);
const scope = [];
for (const [name, path, write] of [['denied-read', c.canary, false], ['input-write', c.input, true]]) {
  try {
    if (write) fs.writeFileSync(path, 'Unexpected mutation'); else fs.readFileSync(path);
    scope.push({ name, ok: true });
  } catch (error) { scope.push({ name, ok: false, code: error.code }); }
}
fs.writeFileSync(c.scope, JSON.stringify(scope));
setInterval(() => fs.appendFileSync(c.heartbeat, '.'), 40);
setTimeout(() => process.exit(0), 5000);
`;
const childCode = `
const { spawn } = require('node:child_process');
const fs = require('node:fs');
const c = JSON.parse(process.argv[1]);
const grandchild = spawn(process.execPath, ['-e', ${JSON.stringify(grandchildCode)}, process.argv[1], c.nonce],
  { detached: c.detached, stdio: 'ignore' });
fs.writeFileSync(c.pids, JSON.stringify([process.pid, grandchild.pid]));
setInterval(() => {}, 1000);
setTimeout(() => process.exit(0), 5000);
`;
const leaderCode = `
const { spawn } = require('node:child_process');
const c = JSON.parse(process.argv[1]);
const child = spawn(process.execPath, ['-e', ${JSON.stringify(childCode)}, process.argv[1], c.nonce],
  { detached: c.detached, stdio: 'ignore' });
child.unref();
console.log('ready');
if (c.mode === 'normal-exit') setTimeout(() => process.exit(0), 150);
else setInterval(() => {}, 1000);
`;

function alive(pid) {
  try {
    const state = execFileSync('/bin/ps', ['-p', String(pid), '-o', 'stat='], { encoding: 'utf8', timeout: 1000 }).trim();
    return state.length > 0 && !state.startsWith('Z');
  } catch { return false; }
}
function ownedPids() {
  if (!fs.existsSync(config.pids)) return [];
  const pids = JSON.parse(fs.readFileSync(config.pids, 'utf8'));
  if (!Array.isArray(pids) || pids.length !== 2 || pids.some(pid => !Number.isInteger(pid) || pid <= 1)) {
    throw new Error('Invalid synthetic process receipt');
  }
  return pids;
}
function killOwned(pid) {
  if (!alive(pid)) return;
  const command = execFileSync('/bin/ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8', timeout: 1000 });
  if (!command.includes(nonce)) throw new Error('Refusing to signal a PID without this reproduction nonce');
  try { process.kill(pid, 'SIGKILL'); } catch (error) { if (error.code !== 'ESRCH') throw error; }
}

let child, timer, outcome, report, stdout = '', stderr = '';
let killCalled = false, waitCompleted = false, disposeCalled = false, warnings = [];
try {
  const request = {
    containment: { type: 'seatbelt', config: { nestedPty: false, guiAccess: false, keychainAccess: false } },
    command: [node, '-e', leaderCode, JSON.stringify(config), nonce].map(quote).join(' '),
    workingDirectory: work,
    filesystem: { readonlyPaths: [input, ...runtimeReadPaths()], readwritePaths: [work], deniedPaths: [canary] },
    network: { egress: { default: 'deny' }, ingress: { default: 'deny', hostLoopback: 'deny' } },
    ui: { disable: true, clipboard: 'none', allowInputInjection: false },
    timeoutMs: mode === 'timeout' ? 650 : 1500,
    inheritDefaultEnvironment: false,
    environment: { PATH: '/usr/bin:/bin:/usr/sbin:/sbin', HOME: join(work, 'home'), TMPDIR: join(work, 'tmp') },
  };
  child = await mxcSpawn(request, { telemetry: { enabled: false } });
  child.standardOutput?.setEncoding('utf8').on('data', chunk => {
    stdout += chunk;
    if (mode === 'cancel' && !timer && stdout.includes('ready')) timer = setTimeout(() => {
      child.kill(); killCalled = true;
    }, 250);
  });
  child.standardError?.setEncoding('utf8').on('data', chunk => { stderr += chunk; });
  child.standardInput?.on('error', () => {});
  child.standardInput?.end();
  try { outcome = await child.wait(); waitCompleted = true; warnings = [...child.warnings]; }
  finally { clearTimeout(timer); child.dispose(); disposeCalled = true; child = undefined; }
  const pids = ownedPids();
  if (pids.length !== 2 || !fs.existsSync(config.scope)) throw new Error('Both descendants must start before interpreting a cleanup result');
  await delay(100);
  const survivors = pids.filter(alive).length;
  const before = fs.existsSync(config.heartbeat) ? fs.statSync(config.heartbeat).size : 0;
  await delay(100);
  const after = fs.existsSync(config.heartbeat) ? fs.statSync(config.heartbeat).size : 0;
  report = { mode, detached, node: process.version, architecture: process.arch,
    outcome, killCalled, waitCompleted, disposeCalled, warnings, survivors,
    heartbeatBytesBefore: before, heartbeatBytesAfter: after,
    scope: JSON.parse(fs.readFileSync(config.scope, 'utf8')), stderr };
} finally {
  clearTimeout(timer);
  if (child) child.dispose();
  // This host supervision is outside MXC and is not evidence of SDK cleanup.
  let pids = ownedPids();
  if (pids.length === 0) { await delay(200); pids = ownedPids(); }
  for (const pid of pids) killOwned(pid);
  for (let i = 0; i < 55 && pids.some(alive); i++) await delay(100);
  if (pids.some(alive)) throw new Error('Owned descendants are still alive; temporary files retained');
  fs.rmSync(scratch, { recursive: true, force: true });
  if (report) report.hostCleanup = { recordedDescendants: pids.length, remainingAlive: 0, scratchRemoved: !fs.existsSync(scratch) };
}
console.log(JSON.stringify(report, null, 2));

Activity

  1. added
    OS-MacOSApplies specifically to macOS hosts or macOS behavior.
    Priority2High-impact issue affecting key functionality; prioritize for the next appropriate release.
    Backend-SeatbeltmacOS process isolation implemented with Seatbelt.
    and removed
    Needs-TriageHas not yet been reviewed, classified, and routed.
    on Oct 14, 2026
  2. theelliotm commented on Oct 16, 2026

    @theelliotm
    Contributor

    Thank you for your bug report. Investigating this.

  3. added
    Needs-AttentionRequires attention or a decision from the MXC maintainers.
    and removed
    Priority2High-impact issue affecting key functionality; prioritize for the next appropriate release.
    on Oct 26, 2026
  4. ugurkocde commented on Oct 31, 2026

    @ugurkocde

    Additional data point from the raw executor, without the SDK, in case it helps narrow the contract question.

    Environment: macOS 27.0.1 on Apple Silicon, mxc-exec-mac from the v1.0.1 release archive, Seatbelt, network denied, one read-only grant and one writable workspace. Each run passes a 2000 ms process.timeout. The leader starts one descendant and then either exits immediately ("normal") or runs /bin/sleep 60 ("timeout"). I counted surviving descendants one second after mxc-exec-mac returned, then killed them.

    Descendant Leader end Executor exit Survivors after 1 s
    /bin/sleep 40 & (same session) normal exit 0 1
    /bin/sleep 40 & (same session) timeout 255 0
    perl fork + POSIX::setsid() + exec sleep normal exit 0 1
    perl fork + POSIX::setsid() + exec sleep timeout 255 1
    C helper, posix_spawn with POSIX_SPAWN_SETSID normal exit 0 1
    C helper, posix_spawn with POSIX_SPAWN_SETSID timeout 255 1

    Two differences from the SDK results above:

    • Through the CLI, an ordinary same-session background child also outlives a normal leader exit. Only the timeout path sweeps the session.
    • posix_spawn(POSIX_SPAWN_SETSID) behaves like setsid(). A fix that intercepts or forbids only the setsid call would miss it.

    Config used (only commandLine varied):

    {
      "version": "1.0.0",
      "containment": "seatbelt",
      "filesystem": { "readonlyPaths": ["<dir>/bin"], "readwritePaths": ["<dir>/ws"], "deniedPaths": [] },
      "network": { "egress": { "default": "deny" }, "ingress": { "default": "deny", "hostLoopback": "deny" } },
      "process": {
        "commandLine": "/usr/bin/perl -MPOSIX -e 'if (fork()==0) { POSIX::setsid(); exec(\"/bin/sleep\",\"41\") }'; true",
        "cwd": "<dir>/ws",
        "env": ["HOME=<dir>/ws", "PATH=/usr/bin:/bin"],
        "timeout": 2000
      }
    }

    The posix_spawn helper:

    #include <spawn.h>
    #include <stdio.h>
    extern char **environ;
    int main(void) {
      posix_spawnattr_t a; posix_spawnattr_init(&a);
      posix_spawnattr_setflags(&a, POSIX_SPAWN_SETSID);
      pid_t pid; char *argv[] = {"/bin/sleep", "30", NULL};
      return posix_spawn(&pid, "/bin/sleep", NULL, &a, argv, environ);
    }

    As in the original report, the survivors stay inside the Seatbelt profile. This concerns lifetime and cleanup, not policy. I describe a working ownership mechanism in #1527.

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

    Backend-SeatbeltmacOS process isolation implemented with Seatbelt.Needs-AttentionRequires attention or a decision from the MXC maintainers.OS-MacOSApplies specifically to macOS hosts or macOS behavior.

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions