Repository navigation
Description
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 ordinaryThe 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
theelliotm commentedon Oct 16, 2026
Thank you for your bug report. Investigating this.
ugurkocde commentedon Oct 31, 2026
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-macfrom the v1.0.1 release archive, Seatbelt, network denied, one read-only grant and one writable workspace. Each run passes a 2000 msprocess.timeout. The leader starts one descendant and then either exits immediately ("normal") or runs/bin/sleep 60("timeout"). I counted surviving descendants one second aftermxc-exec-macreturned, then killed them./bin/sleep 40 &(same session)/bin/sleep 40 &(same session)fork+POSIX::setsid()+ exec sleepfork+POSIX::setsid()+ exec sleepposix_spawnwithPOSIX_SPAWN_SETSIDposix_spawnwithPOSIX_SPAWN_SETSIDTwo differences from the SDK results above:
posix_spawn(POSIX_SPAWN_SETSID)behaves likesetsid(). A fix that intercepts or forbids only thesetsidcall would miss it.Config used (only
commandLinevaried):{ "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_spawnhelper: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.