diff --git a/.github/workflows/seatbelt-system-keychains.yml b/.github/workflows/seatbelt-system-keychains.yml new file mode 100644 index 0000000000..b28a089f06 --- /dev/null +++ b/.github/workflows/seatbelt-system-keychains.yml @@ -0,0 +1,42 @@ +name: Seatbelt system Keychains smoke + +on: + pull_request: + paths: + - 'cli/src/lib/sandbox/seatbelt.js' + - 'cli/__tests__/seatbelt.system-keychains.smoke.test.mjs' + - 'cli/__tests__/seatbelt.claude-keychain-read.smoke.test.mjs' + - '.github/workflows/seatbelt-system-keychains.yml' + +permissions: + contents: read + +jobs: + adapter-turns: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: cli/package-lock.json + - run: npm ci --prefix cli + - name: Install tested OpenCode for a disposable adapter turn + run: npm install --prefix "$RUNNER_TEMP/opencode-smoke" opencode-ai@1.19.0 + - name: Exercise system Keychains denials and real adapter turns + env: + COMMONLY_OPENCODE_SMOKE_BINARY: ${{ runner.temp }}/opencode-smoke/node_modules/.bin/opencode + # Writes a probe item into this disposable runner's login keychain. + COMMONLY_KEYCHAIN_PROBE: '1' + run: | + cd cli + node --experimental-vm-modules node_modules/.bin/jest \ + __tests__/seatbelt.system-keychains.smoke.test.mjs \ + __tests__/seatbelt.claude-keychain-read.smoke.test.mjs \ + __tests__/adapters.opencode.seatbelt.smoke.test.mjs \ + __tests__/adapters.qwen.seatbelt.smoke.test.mjs \ + __tests__/seatbelt.claude-runtime.smoke.test.mjs \ + --runInBand --testTimeout=60000 diff --git a/cli/__tests__/seatbelt.claude-keychain-read.smoke.test.mjs b/cli/__tests__/seatbelt.claude-keychain-read.smoke.test.mjs new file mode 100644 index 0000000000..2127abce54 --- /dev/null +++ b/cli/__tests__/seatbelt.claude-keychain-read.smoke.test.mjs @@ -0,0 +1,77 @@ +import { spawnSync } from 'child_process'; +import { mkdirSync, mkdtempSync, rmSync } from 'fs'; +import { homedir, tmpdir } from 'os'; +import { join } from 'path'; +import { randomBytes } from 'crypto'; +import { buildSeatbeltProfile } from '../src/lib/sandbox/seatbelt.js'; + +/** + * Does Claude's credential read survive the removal of the system Keychains + * folder from the baseline? Claude reads its OAuth item by running + * /usr/bin/security, whose default search list includes System.keychain. + * If Security.framework treats a denied search-list keychain as fatal, every + * public Claude seat would fail to log in after the change ships (TASK-233, + * Otto's Ask 2 on eca2d8d79). + * + * This writes a probe item into the login keychain, so it runs only when the + * disposable macOS job sets COMMONLY_KEYCHAIN_PROBE=1. It never runs on an + * operator host. + */ +const probeEnabled = process.platform === 'darwin' && process.env.COMMONLY_KEYCHAIN_PROBE === '1'; +const macProbe = probeEnabled ? test : test.skip; + +const SERVICE = 'commonly-seatbelt-probe'; +const loginKeychain = join(homedir(), 'Library', 'Keychains', 'login.keychain-db'); +const secret = randomBytes(16).toString('hex'); +const root = probeEnabled ? mkdtempSync(join(tmpdir(), 'commonly-keychain-probe-')) : null; +const workspace = root && join(root, 'workspace'); +const state = root && join(root, 'state'); +if (root) { + mkdirSync(workspace); + mkdirSync(state); +} + +beforeAll(() => { + if (!probeEnabled) return; + const added = spawnSync('/usr/bin/security', [ + 'add-generic-password', '-U', '-s', SERVICE, '-a', 'commonly', '-w', secret, loginKeychain, + ], { encoding: 'utf8' }); + if (added.status !== 0) throw new Error(`could not write the probe item: ${added.stderr}`); +}); + +afterAll(() => { + if (!probeEnabled) return; + spawnSync('/usr/bin/security', ['delete-generic-password', '-s', SERVICE, loginKeychain], { encoding: 'utf8' }); + rmSync(root, { recursive: true, force: true }); +}); + +// A confined Node spawns security, as the Claude CLI does. The profile is the +// Claude runtime profile, or that profile plus one extra rule for a control. +const readProbe = (extraRules = '') => { + const profile = `${buildSeatbeltProfile({ + workspacePath: workspace, executablePath: process.execPath, statePath: state, allowClaudeRuntimeAccess: true, + })}\n${extraRules}`; + return spawnSync('/usr/bin/sandbox-exec', [ + '-p', profile, process.execPath, '-e', + `const { spawnSync } = require('child_process'); + const r = spawnSync('/usr/bin/security', ['find-generic-password', '-s', '${SERVICE}', '-w'], { encoding: 'utf8' }); + process.stdout.write(String(r.stdout).trim()); process.stderr.write(String(r.stderr).trim()); process.exitCode = r.status;`, + ], { cwd: workspace, encoding: 'utf8', timeout: 20_000 }); +}; + +macProbe('the probe item is readable outside any sandbox (precondition)', () => { + const result = spawnSync('/usr/bin/security', ['find-generic-password', '-s', SERVICE, '-w'], { encoding: 'utf8' }); + expect({ status: result.status, stdout: result.stdout.trim() }).toEqual({ status: 0, stdout: secret }); +}); + +macProbe('the Claude runtime profile still reads its credential with System.keychain denied', () => { + const result = readProbe(); + expect({ status: result.status, stdout: result.stdout, stderr: result.stderr }).toEqual({ + status: 0, stdout: secret, stderr: '', + }); +}); + +macProbe('control: the old broad Keychains folder grant also reads it', () => { + const result = readProbe('(allow file-read* (subpath "/Library/Keychains"))'); + expect({ status: result.status, stdout: result.stdout }).toEqual({ status: 0, stdout: secret }); +}); diff --git a/cli/__tests__/seatbelt.system-keychains.smoke.test.mjs b/cli/__tests__/seatbelt.system-keychains.smoke.test.mjs new file mode 100644 index 0000000000..8dfc35440c --- /dev/null +++ b/cli/__tests__/seatbelt.system-keychains.smoke.test.mjs @@ -0,0 +1,68 @@ +import { spawnSync } from 'child_process'; +import { mkdirSync, mkdtempSync, rmSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import { buildSeatbeltProfile } from '../src/lib/sandbox/seatbelt.js'; + +const macTest = process.platform === 'darwin' ? test : test.skip; +const root = process.platform === 'darwin' + ? mkdtempSync(join(homedir(), '.commonly-keychain-smoke-')) : null; +const workspace = root && join(root, 'workspace'); +const state = root && join(root, 'state'); +if (root) { + mkdirSync(workspace); + mkdirSync(state); +} +afterAll(() => { if (root) rmSync(root, { recursive: true, force: true }); }); + +// Two profiles reach the Keychain, and only the Claude one passes this flag. +// The denial must hold under both, or a grant added to claudeRuntimeRules +// would go unnoticed (TASK-233, Otto's mutation on eca2d8d79). +const PROFILES = [ + { label: 'public', options: {} }, + { label: 'Claude runtime', options: { allowClaudeRuntimeAccess: true } }, +]; + +const run = (code, options = {}) => spawnSync('/usr/bin/sandbox-exec', [ + '-p', buildSeatbeltProfile({ + workspacePath: workspace, executablePath: process.execPath, statePath: state, ...options, + }), process.execPath, '-e', code, +], { + cwd: workspace, encoding: 'utf8', timeout: 15_000, +}); + +describe.each(PROFILES)('$label Seatbelt', ({ options }) => { + macTest('denies listing and opening system Keychains', () => { + const result = run(`const fs = require('fs'); + for (const path of ['/Library/Keychains', '/Library/Keychains/System.keychain', + '/Library/Keychains/apsd.keychain']) { + try { + if (path.endsWith('/Keychains')) fs.readdirSync(path); + else fs.closeSync(fs.openSync(path, 'r')); + console.log('ALLOWED'); + } catch (error) { console.log(error.code); } + }`, options); + expect(result.error).toBeUndefined(); + expect({ status: result.status, stderr: result.stderr }).toEqual({ status: 0, stderr: '' }); + expect(result.stdout.trim().split('\n')).toEqual(['EPERM', 'EPERM', 'EPERM']); + }); + + // Node reads this file at startup with no OPENSSL_CONF override, so the + // literal grant in PLATFORM_BASELINE must let the read through (TASK-233). + macTest('reads the system OpenSSL config that Node loads at startup', () => { + const result = run(`const fs = require('fs'); + try { fs.readFileSync('/System/Library/OpenSSL/openssl.cnf'); console.log('READ'); } + catch (error) { console.log(error.code); }`, options); + expect(result.error).toBeUndefined(); + expect(result.stdout.trim()).toBe('READ'); + }); +}); + +macTest('Node can complete HTTPS with the system Keychains folder denied', () => { + const result = run(`fetch('https://example.com') + .then((response) => { console.log(response.status); }) + .catch((error) => { console.error(error.message); process.exitCode = 1; });`); + expect(result.error).toBeUndefined(); + expect({ status: result.status, stdout: result.stdout.trim(), stderr: result.stderr }) + .toEqual({ status: 0, stdout: '200', stderr: '' }); +}); diff --git a/cli/src/lib/sandbox/seatbelt.js b/cli/src/lib/sandbox/seatbelt.js index 33419115ea..d0cbbee421 100644 --- a/cli/src/lib/sandbox/seatbelt.js +++ b/cli/src/lib/sandbox/seatbelt.js @@ -45,7 +45,8 @@ const PLATFORM_BASELINE = String.raw` (path "/dev/null") (vnode-type CHARACTER-DEVICE))) -; Loader + standard system resources. +; Loader + standard system resources. Node opens this one macOS OpenSSL config +; file at startup; the macOS adapter job's Node was denied it without this rule. (allow file-read* file-test-existence (subpath "/Library/Apple") (subpath "/Library/Filesystems/NetFSPlugins") @@ -54,8 +55,8 @@ const PLATFORM_BASELINE = String.raw` (subpath "/private/var/db/timezone") (subpath "/usr/lib") (subpath "/usr/share") + (literal "/System/Library/OpenSSL/openssl.cnf") (subpath "/Library/Preferences") - (subpath "/Library/Keychains") (subpath "/var/db") (subpath "/private/var/db")) (allow file-read* file-test-existence