From cc611710c362fa99d13656ddc7d3f8de784e7afd Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:40:55 -0700 Subject: [PATCH 1/4] fix(cli): confined Seatbelt admits the resolver's DNS configuration service (D106) A confined dsh turn hung before sending any request and ran to its 900 s timeout. A sampled hung dsh process was blocked on its main thread in c-ares init: ares_init_sysconfig_macos -> dns_configuration_copy -> an xpc bootstrap lookup of com.apple.SystemConfiguration.DNSConfiguration, which the loopback profile denied. The denial does not always fail fast; at times it never returns. Move that one mach-lookup out of the open-network block into a rule both network modes carry. It returns resolver configuration only. mDNSResponder and port 53 stay unreachable in a loopback profile, so lookups fail fast with ENOTFOUND and TCP to outside hosts is still EPERM. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DRK3nqxjeAV7xL8uhg --- cli/__tests__/seatbelt.test.mjs | 42 ++++++++++++++++++++++++++++++++- cli/src/lib/sandbox/seatbelt.js | 15 ++++++++++-- docs/cli/dsh.md | 8 +++++-- 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/cli/__tests__/seatbelt.test.mjs b/cli/__tests__/seatbelt.test.mjs index 61688ad6e3..45aea7e90f 100644 --- a/cli/__tests__/seatbelt.test.mjs +++ b/cli/__tests__/seatbelt.test.mjs @@ -1,6 +1,7 @@ import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync, } from 'fs'; +import { spawnSync } from 'child_process'; import { homedir, tmpdir } from 'os'; import { join } from 'path'; @@ -234,9 +235,13 @@ describe('macOS Seatbelt profile', () => { const loopback = buildSeatbeltProfile({ ...base, network: 'loopback' }); expect(loopback).not.toMatch(/^\(allow network-outbound\)$/m); expect(loopback).not.toContain('AF_SYSTEM'); - for (const service of ['SecurityServer', 'securityd', 'ocspd', 'networkd', 'SystemConfiguration']) { + for (const service of ['SecurityServer', 'securityd', 'ocspd', 'networkd', 'SystemConfiguration.configd']) { expect(loopback).not.toContain(`com.apple.${service}`); } + // The resolver's configuration only: without it the first hostname lookup + // blocks the process forever (D106), see the sandboxed test below. + const configServices = loopback.match(/com\.apple\.SystemConfiguration\.\w+/g); + expect(configServices).toEqual(['com.apple.SystemConfiguration.DNSConfiguration']); expect(loopback).toContain(`(allow network-outbound (remote tcp "localhost:${port}"))`); // Every line a loopback profile carries, the open one carries too. const openLines = new Set(open.split('\n')); @@ -333,6 +338,41 @@ describe('macOS Seatbelt profile', () => { })).toThrow(/workspacePath must be an absolute path/); }); + test('a loopback-confined process builds a DNS resolver and still reaches no outside host', () => { + if (process.platform !== 'darwin') return; + // D106: the profile once denied the resolver's configuration, and + // constructing a c-ares Resolver could block the process forever, so + // confined dsh turns ran to their 900 s timeout before sending a request. + // That hang was intermittent, so this run cannot catch its return on its + // own; the profile-shape test above pins the rule. This one proves the + // admitted service still lets nothing out. + const script = [ + "const dns = require('dns'); const net = require('net');", + "new dns.Resolver().resolve4('example.com', (err) => {", + " console.log('resolve4', err ? err.code : 'resolved');", + " dns.lookup('example.com', (e) => {", + " console.log('lookup', e ? e.code : 'resolved');", + " const s = net.connect(443, '1.1.1.1', () => { console.log('tcp connected'); process.exit(0); });", + " s.on('error', (e2) => { console.log('tcp', e2.code); process.exit(0); });", + " });", + "});", + ].join('\n'); + const argv = wrapArgvWithSeatbelt([process.execPath, '-e', script], { + workspacePath: workspace, + executablePath: process.execPath, + statePath: state, + network: 'loopback', + loopbackNetworkPorts: [9], + }); + const run = spawnSync(argv[0], argv.slice(1), { + env: { PATH: process.env.PATH, HOME: state }, encoding: 'utf8', timeout: 20000, + }); + expect(run.error).toBeUndefined(); + expect(run.stdout).toMatch(/resolve4 E(?:CONNREFUSED|TIMEOUT|NOTFOUND)/); + expect(run.stdout).toContain('lookup ENOTFOUND'); + expect(run.stdout).toContain('tcp EPERM'); + }, 30000); + test('wrapper is fail-closed off macOS and invokes sandbox-exec on macOS', () => { if (process.platform !== 'darwin') { expect(() => wrapArgvWithSeatbelt(['/usr/bin/true'], { diff --git a/cli/src/lib/sandbox/seatbelt.js b/cli/src/lib/sandbox/seatbelt.js index e782cd9f50..2f8d37c3ba 100644 --- a/cli/src/lib/sandbox/seatbelt.js +++ b/cli/src/lib/sandbox/seatbelt.js @@ -289,7 +289,6 @@ const OPEN_NETWORK = String.raw` (socket-protocol 2))) (allow mach-lookup (global-name "com.apple.SecurityServer") - (global-name "com.apple.SystemConfiguration.DNSConfiguration") (global-name "com.apple.SystemConfiguration.configd") (global-name "com.apple.networkd") (global-name "com.apple.ocspd") @@ -297,6 +296,18 @@ const OPEN_NETWORK = String.raw` (global-name "com.apple.securityd.xpc")) `; +// Both network modes. On macOS, c-ares (node's dns.Resolver and dns.resolve*) +// reads the resolver list from configd's DNSConfiguration service while the +// Resolver is constructed. Denied, that lookup does not always fail: at +// times it blocks the main thread forever. dsh builds a Resolver as it loads, +// so confined turns ran to their 900 s timeout without sending a request +// (D106). Admitted, resolution fails fast, because mDNSResponder and port 53 +// stay unreachable in a loopback profile. The service returns configuration +// and opens no channel out. +const RESOLVER_CONFIG = String.raw` +(allow mach-lookup (global-name "com.apple.SystemConfiguration.DNSConfiguration")) +`; + const escapeSbplString = (value) => String(value) .replaceAll('\\', '\\\\') .replaceAll('"', '\\"') @@ -466,7 +477,7 @@ export const buildSeatbeltProfile = ({ })() : []; return [ - (network === 'open' ? `${PLATFORM_BASELINE}${OPEN_NETWORK}` : PLATFORM_BASELINE).trim(), + `${PLATFORM_BASELINE}${RESOLVER_CONFIG}${network === 'open' ? OPEN_NETWORK : ''}`.trim(), executableRules, executableSymlinkRules, `(allow file-read-metadata file-test-existence (path-ancestors "${escapeSbplString(resolvedWorkspace)}"))`, diff --git a/docs/cli/dsh.md b/docs/cli/dsh.md index aabe139359..cc77026017 100644 --- a/docs/cli/dsh.md +++ b/docs/cli/dsh.md @@ -85,8 +85,12 @@ macOS using CLI 0.1.117, then re-walked on CLI 0.1.122 on 2026-10-23. ## Confined mode (macOS) -Confined mode does not complete turns yet on CLI 0.1.191, so run the private -seat unconfined until a fix ships. +Through CLI 0.1.191, a confined turn could hang before it sent any request and +run to its 900 s timeout. The profile denied the macOS resolver's configuration +service, and node's DNS resolver, which dsh builds as it loads, can block +forever on that denial. The profile now admits that one service, which returns +configuration only, so resolution fails fast and nothing else gets out. Use a +CLI release that carries this fix before running a seat confined. A seat that declares exactly this, and nothing else under `sandbox`, runs confined: From 86f3f3b3c290cc9c04c7bb766a306f81bae6c18a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:21:25 -0700 Subject: [PATCH 2/4] docs(cli): install the latest CLI for dsh, not 0.1.122, which has the confined hang The release that carries the fix publishes on merge; a follow-up names its version. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DRK3nqxjeAV7xL8uhg --- docs/cli/dsh.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cli/dsh.md b/docs/cli/dsh.md index cc77026017..b1fae8e8a8 100644 --- a/docs/cli/dsh.md +++ b/docs/cli/dsh.md @@ -13,7 +13,7 @@ setting. Linux and Windows have not been walked. Use Node.js 20 or newer: ```bash -npm i -g @commonlyai/cli@0.1.122 @deepseek-ai/dsh@0.2.1-rc.2 +npm i -g @commonlyai/cli@latest @deepseek-ai/dsh@0.2.1-rc.2 commonly login ``` @@ -85,7 +85,7 @@ macOS using CLI 0.1.117, then re-walked on CLI 0.1.122 on 2026-10-23. ## Confined mode (macOS) -Through CLI 0.1.191, a confined turn could hang before it sent any request and +Before this fix, a confined turn could hang before it sent any request and run to its 900 s timeout. The profile denied the macOS resolver's configuration service, and node's DNS resolver, which dsh builds as it loads, can block forever on that denial. The profile now admits that one service, which returns From c07488ef3c890e3640ba972bbfdfccedc664f1fb Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:39:08 -0700 Subject: [PATCH 3/4] test(cli): pin the resolver rule's verb, skip the live run off macOS, kill a hung run at its timeout - Both profiles must carry the exact `(allow mach-lookup (global-name "com.apple.SystemConfiguration.DNSConfiguration"))` line, it must be the only DNSConfiguration line, and no bare `(allow mach-lookup)` may appear. A deny (vera's M6) and a bare allow (M5) now both fail the shape test. - The live sandboxed test is test.skip off darwin, so Linux CI reports it as skipped instead of passed. - spawnSync kills with SIGKILL at its 20 s timeout. A child blocked on a denied lookup outlived SIGTERM and hung the run; sandbox-exec runs the child in place, so SIGKILL returns at the timeout with nothing left behind. - dsh.md: the admitted service "opens no network route of its own" instead of "nothing else gets out", which the Limits section contradicts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DRK3nqxjeAV7xL8uhg --- cli/__tests__/seatbelt.test.mjs | 17 ++++++++++++++--- docs/cli/dsh.md | 3 ++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/cli/__tests__/seatbelt.test.mjs b/cli/__tests__/seatbelt.test.mjs index 45aea7e90f..31eddc1e01 100644 --- a/cli/__tests__/seatbelt.test.mjs +++ b/cli/__tests__/seatbelt.test.mjs @@ -242,6 +242,14 @@ describe('macOS Seatbelt profile', () => { // blocks the process forever (D106), see the sandboxed test below. const configServices = loopback.match(/com\.apple\.SystemConfiguration\.\w+/g); expect(configServices).toEqual(['com.apple.SystemConfiguration.DNSConfiguration']); + // The verb, not only the name: a deny here is the D106 hang itself, and a + // bare allow would admit every service. Both modes carry the same rule. + const resolverRule = '(allow mach-lookup (global-name "com.apple.SystemConfiguration.DNSConfiguration"))'; + for (const profile of [open, loopback]) { + expect(profile.split('\n')).toContain(resolverRule); + expect(profile).not.toMatch(/^\(allow mach-lookup\)$/m); + expect(profile.split('\n').filter((line) => line.includes('DNSConfiguration'))).toEqual([resolverRule]); + } expect(loopback).toContain(`(allow network-outbound (remote tcp "localhost:${port}"))`); // Every line a loopback profile carries, the open one carries too. const openLines = new Set(open.split('\n')); @@ -338,8 +346,9 @@ describe('macOS Seatbelt profile', () => { })).toThrow(/workspacePath must be an absolute path/); }); - test('a loopback-confined process builds a DNS resolver and still reaches no outside host', () => { - if (process.platform !== 'darwin') return; + // Off macOS there is no sandbox-exec to run it under, so it reports as + // skipped rather than passing without running. + (process.platform === 'darwin' ? test : test.skip)('a loopback-confined process builds a DNS resolver and still reaches no outside host', () => { // D106: the profile once denied the resolver's configuration, and // constructing a c-ares Resolver could block the process forever, so // confined dsh turns ran to their 900 s timeout before sending a request. @@ -365,7 +374,9 @@ describe('macOS Seatbelt profile', () => { loopbackNetworkPorts: [9], }); const run = spawnSync(argv[0], argv.slice(1), { - env: { PATH: process.env.PATH, HOME: state }, encoding: 'utf8', timeout: 20000, + // SIGKILL: a child blocked on a denied resolver lookup outlived the + // default SIGTERM, so a regression hung the run instead of failing. + env: { PATH: process.env.PATH, HOME: state }, encoding: 'utf8', timeout: 20000, killSignal: 'SIGKILL', }); expect(run.error).toBeUndefined(); expect(run.stdout).toMatch(/resolve4 E(?:CONNREFUSED|TIMEOUT|NOTFOUND)/); diff --git a/docs/cli/dsh.md b/docs/cli/dsh.md index b1fae8e8a8..274bfbd26f 100644 --- a/docs/cli/dsh.md +++ b/docs/cli/dsh.md @@ -89,7 +89,8 @@ Before this fix, a confined turn could hang before it sent any request and run to its 900 s timeout. The profile denied the macOS resolver's configuration service, and node's DNS resolver, which dsh builds as it loads, can block forever on that denial. The profile now admits that one service, which returns -configuration only, so resolution fails fast and nothing else gets out. Use a +configuration only and opens no network route of its own, so resolution fails +fast. Use a CLI release that carries this fix before running a seat confined. A seat that declares exactly this, and nothing else under `sandbox`, runs From 22949ed190110b1cf04c542776e16bcd556b6185 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:20:52 -0700 Subject: [PATCH 4/4] test(cli): catch a bare mach-lookup allow in any whitespace spelling vera showed `(allow mach-lookup )` slipped past the exact-line check while SBPL reads it as the same bare allow. The check is now whitespace-tolerant, with positive controls for four spellings. The SIGKILL comment no longer claims the hung child outlived SIGTERM; in vera's run SIGTERM ended it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DRK3nqxjeAV7xL8uhg --- cli/__tests__/seatbelt.test.mjs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/cli/__tests__/seatbelt.test.mjs b/cli/__tests__/seatbelt.test.mjs index 31eddc1e01..80bc328ef6 100644 --- a/cli/__tests__/seatbelt.test.mjs +++ b/cli/__tests__/seatbelt.test.mjs @@ -245,9 +245,17 @@ describe('macOS Seatbelt profile', () => { // The verb, not only the name: a deny here is the D106 hang itself, and a // bare allow would admit every service. Both modes carry the same rule. const resolverRule = '(allow mach-lookup (global-name "com.apple.SystemConfiguration.DNSConfiguration"))'; + // SBPL ignores whitespace inside a form, so a bare allow can be spelled + // with spaces or a line break before the close; any spelling admits every + // service. A multi-line rule opens `(allow mach-lookup` and continues with + // `(global-name`, so it never matches. + const bareAllow = /\(\s*allow\s+mach-lookup\s*\)/; + for (const variant of ['(allow mach-lookup)', '(allow mach-lookup )', '( allow mach-lookup)', '(allow mach-lookup\n)']) { + expect(`${loopback}\n${variant}\n`).toMatch(bareAllow); + } for (const profile of [open, loopback]) { expect(profile.split('\n')).toContain(resolverRule); - expect(profile).not.toMatch(/^\(allow mach-lookup\)$/m); + expect(profile).not.toMatch(bareAllow); expect(profile.split('\n').filter((line) => line.includes('DNSConfiguration'))).toEqual([resolverRule]); } expect(loopback).toContain(`(allow network-outbound (remote tcp "localhost:${port}"))`); @@ -374,8 +382,8 @@ describe('macOS Seatbelt profile', () => { loopbackNetworkPorts: [9], }); const run = spawnSync(argv[0], argv.slice(1), { - // SIGKILL: a child blocked on a denied resolver lookup outlived the - // default SIGTERM, so a regression hung the run instead of failing. + // SIGKILL, so a child blocked on a denied resolver lookup cannot hold + // the run past the timeout whether or not it honours SIGTERM. env: { PATH: process.env.PATH, HOME: state }, encoding: 'utf8', timeout: 20000, killSignal: 'SIGKILL', }); expect(run.error).toBeUndefined();