Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion cli/__tests__/seatbelt.test.mjs
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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'));
Expand Down Expand Up @@ -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'], {
Expand Down
15 changes: 13 additions & 2 deletions cli/src/lib/sandbox/seatbelt.js
Original file line number Diff line number Diff line change
Expand Up @@ -289,14 +289,25 @@ 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")
(global-name "com.apple.securityd")
(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('"', '\\"')
Expand Down Expand Up @@ -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)}"))`,
Expand Down
11 changes: 8 additions & 3 deletions docs/cli/dsh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
commonly login
```

Expand Down Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

A seat that declares exactly this, and nothing else under `sandbox`, runs
confined:
Expand Down
Loading