diff --git a/cli/__tests__/seatbelt.test.mjs b/cli/__tests__/seatbelt.test.mjs index 61688ad6e3..80bc328ef6 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,29 @@ 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']); + // 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(bareAllow); + 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')); @@ -333,6 +354,44 @@ describe('macOS Seatbelt profile', () => { })).toThrow(/workspacePath must be an absolute path/); }); + // 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. + // 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), { + // 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(); + 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..274bfbd26f 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,8 +85,13 @@ 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. +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 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 confined: