ReviewOS

also looking at this

craft-native/craft

fix(release): prevent the standalone macOS CLI sandbox crash

#291
Merged glennmichael123 wants to merge codex/macos-cli-signing into main
5 files +120 -31
.github/workflows/ci.ymlmodified+4-0
Changes to .github/workflows/ci.yml
@@ -67,6 +67,10 @@ jobs:
6767 working-directory: packages/zig
6868 run: zig build test
6969
70 - name: Verify standalone macOS signing entitlements
71 if: runner.os == 'macOS'
72 run: bun test scripts/macos-release.test.ts
73
7074 - name: Check binary size
7175 if: runner.os == 'Linux'
7276 run: |
.github/workflows/release.ymlmodified+40-7
Changes to .github/workflows/release.yml
@@ -237,14 +237,9 @@ jobs:
237237
238238 - name: Sign macOS binary
239239 if: runner.os == 'macOS' && env.SIGNING_AVAILABLE == 'true'
240 env:
241 # Default to the strict distribution entitlements; CI can opt in to
242 # the dev plist (sandbox off, library validation off) by setting
243 # CRAFT_ENTITLEMENTS=dev for local debugging — never for release.
244 ENTITLEMENTS_VARIANT: ${{ vars.CRAFT_ENTITLEMENTS || 'distribution' }}
245240 run: |
246241 set -euo pipefail
247 ENTITLEMENTS="scripts/entitlements-${ENTITLEMENTS_VARIANT}.plist"
242 ENTITLEMENTS="scripts/entitlements-distribution.plist"
248243 if [ ! -f "$ENTITLEMENTS" ]; then
249244 echo "::error file=$ENTITLEMENTS::missing — required for hardened-runtime signing"
250245 exit 1
@@ -253,12 +248,21 @@ jobs:
253248 for binary in \
254249 packages/zig/zig-out/bin/craft \
255250 packages/zig/zig-out/cross/darwin-x64/craft; do
256 codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" \
251 codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" \
257252 --entitlements "$ENTITLEMENTS" "$binary"
258253 codesign --verify --deep --strict "$binary"
259254 done
260255 echo "macOS ARM64 and x64 binaries signed successfully"
261256
257 - name: Verify standalone macOS launch and signatures
258 if: runner.os == 'macOS'
259 env:
260 VERSION: ${{ steps.version.outputs.version }}
261 run: |
262 bun test scripts/macos-release.test.ts
263 bun scripts/verify-macos-release.ts packages/zig/zig-out/bin/craft "$VERSION"
264 bun scripts/verify-macos-release.ts packages/zig/zig-out/cross/darwin-x64/craft "$VERSION" --no-execute
265
262266 - name: Notarize macOS binaries
263267 if: runner.os == 'macOS' && env.SIGNING_AVAILABLE == 'true'
264268 run: |
@@ -399,6 +403,35 @@ jobs:
399403 cat /tmp/index.out || true
400404 fi
401405
406 verify-macos-downloads:
407 name: verify downloaded ${{ matrix.platform.name }}
408 needs: [pantry]
409 if: ${{ !cancelled() }}
410 strategy:
411 fail-fast: false
412 matrix:
413 platform:
414 - { os: macos-15, name: darwin-arm64 }
415 - { os: macos-15-intel, name: darwin-x64 }
416 runs-on: ${{ matrix.platform.os }}
417 permissions:
418 contents: read
419 steps:
420 - uses: actions/checkout@v6
421 - name: Setup Pantry
422 uses: pantry-pm/pantry/packages/action@235036fa0f48bae99b2293df5a3dc35c809b1777
423 - name: Verify the final downloadable executable
424 env:
425 GH_TOKEN: ${{ secrets.PAT_TOKEN || secrets.GITHUB_TOKEN }}
426 TAG: ${{ github.ref_name }}
427 PLATFORM: ${{ matrix.platform.name }}
428 run: |
429 set -euo pipefail
430 DOWNLOAD_DIR=$(mktemp -d)
431 gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "craft-$PLATFORM.zip" --dir "$DOWNLOAD_DIR"
432 ditto -x -k "$DOWNLOAD_DIR/craft-$PLATFORM.zip" "$DOWNLOAD_DIR/extracted"
433 bun scripts/verify-macos-release.ts "$DOWNLOAD_DIR/extracted/craft" "${TAG#v}"
434
402435 npm:
403436 name: npm
404437 # Ordered after the binaries, not gated on them.
scripts/entitlements-distribution.plistmodified+8-24
Changes to scripts/entitlements-distribution.plist
@@ -1,36 +1,20 @@
11<?xml version="1.0" encoding="UTF-8"?>
22<!--
3 Distribution entitlements for the Craft binary shipped to end users.
3 Distribution entitlements for the standalone Craft CLI installed by Pantry.
44
5 We deliberately request the *minimum* set of capabilities here — the dev
6 plist (`entitlements-dev.plist`) is what disables the sandbox and library
7 validation for local-iteration ergonomics. End users should never run with
8 those flags turned off, since that disables the macOS exploit-mitigation
9 story we otherwise rely on.
5 App Sandbox requires a bundle identity. This raw executable has no embedded
6 Info.plist and must accept user-supplied paths from the shell; enabling App
7 Sandbox here traps in libsecinit before main. Hardened runtime is enabled
8 separately by codesign's runtime option, and library validation stays on.
109
11 If your app needs extra capabilities (camera, microphone, user-selected
12 files, etc.), generate a custom plist via
13 `generateEntitlements({ camera: true, ... })` from the SDK or fork this
14 file in your own project.
10 Application bundles have their own identity and entitlement policy, generated
11 by the SDK. This plist is only for the standalone CLI release artifact.
1512-->
1613<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1714<plist version="1.0">
1815<dict>
19 <!-- Run inside the macOS app sandbox. -->
20 <key>com.apple.security.app-sandbox</key>
21 <true/>
22
23 <!-- WebKit needs JIT for modern JS performance. Limited to the runtime
24 WebView, not arbitrary code. -->
16 <!-- Allow the runtime's JIT under hardened runtime. -->
2517 <key>com.apple.security.cs.allow-jit</key>
2618 <true/>
27
28 <!-- Allow outbound HTTP. Required for the auto-updater and fetch API. -->
29 <key>com.apple.security.network.client</key>
30 <true/>
31
32 <!-- Allow user-selected files (open dialog returns sandbox-scoped URLs). -->
33 <key>com.apple.security.files.user-selected.read-write</key>
34 <true/>
3519</dict>
3620</plist>
scripts/macos-release.test.tsadded+28-0
Changes to scripts/macos-release.test.ts
@@ -0,0 +1,28 @@
1import { expect, test } from 'bun:test'
2import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3import { tmpdir } from 'node:os'
4import { join, resolve } from 'node:path'
5import { verifyMacosRelease } from './verify-macos-release'
6
7test.skipIf(process.platform !== 'darwin')('distribution entitlements allow a standalone signed CLI to start', () => {
8 const temp = mkdtempSync(join(tmpdir(), 'craft-cli-signing-'))
9 const run = (args: string[]): string => {
10 const result = Bun.spawnSync(args, { cwd: temp, stdout: 'pipe', stderr: 'pipe' })
11 expect({ exitCode: result.exitCode, signal: result.signalCode, stderr: result.stderr.toString() })
12 .toMatchObject({ exitCode: 0 })
13 return result.stdout.toString()
14 }
15 try {
16 writeFileSync(join(temp, 'main.c'), '#include <stdio.h>\nint main(void) { puts("craft version 7.8.9"); return 0; }\n')
17 const binary = join(temp, 'craft')
18 run(['xcrun', 'clang', 'main.c', '-o', binary])
19 run(['codesign', '--force', '--sign', '-', '--options', 'runtime', '--entitlements', resolve(import.meta.dir, 'entitlements-distribution.plist'), binary])
20 run(['codesign', '--verify', '--deep', '--strict', binary])
21 expect(run([binary, '--version']).trim()).toBe('craft version 7.8.9')
22 expect(() => verifyMacosRelease(binary, '7.8.9')).not.toThrow()
23 expect(() => verifyMacosRelease(binary, '7.8.10')).toThrow('Expected craft version 7.8.10')
24 }
25 finally {
26 rmSync(temp, { recursive: true, force: true })
27 }
28}, 30_000)
scripts/verify-macos-release.tsadded+40-0
Changes to scripts/verify-macos-release.ts
@@ -0,0 +1,40 @@
1import { resolve } from 'node:path'
2
3function command(args: string[]): string {
4 const result = Bun.spawnSync(args, { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 })
5 if (result.exitCode !== 0)
6 throw new Error(`${args.join(' ')} failed (${result.signalCode ?? result.exitCode})\n${result.stdout}\n${result.stderr}`)
7 return `${result.stdout}\n${result.stderr}`
8}
9
10export function verifyMacosRelease(binary: string, version: string, execute = true): void {
11 if (process.platform !== 'darwin')
12 throw new Error('macOS release verification requires macOS trust services')
13 command(['codesign', '--verify', '--deep', '--strict', '--verbose=4', binary])
14 const metadata = command(['codesign', '--display', '--verbose=4', binary])
15 if (!metadata.includes('Signature=adhoc')) {
16 if (!metadata.includes('Authority=Developer ID Application:') || !metadata.includes('Timestamp='))
17 throw new Error('Developer ID release must have a certificate chain and secure timestamp')
18 if (!/flags=.*\bruntime\b/.test(metadata))
19 throw new Error('Developer ID release must enable hardened runtime')
20 }
21 const entitlements = command(['codesign', '--display', '--entitlements', '-', '--xml', binary])
22 for (const key of ['com.apple.security.app-sandbox', 'com.apple.security.cs.disable-library-validation', 'com.apple.security.cs.allow-unsigned-executable-memory']) {
23 const escaped = key.replaceAll('.', '\\.')
24 if (new RegExp(`<key>${escaped}</key>\\s*<true\\s*/>`).test(entitlements))
25 throw new Error(`Standalone release must not enable ${key}`)
26 }
27 if (execute) {
28 const output = command([binary, '--version'])
29 if (output.trim().split('\n')[0] !== `craft version ${version}`)
30 throw new Error(`Expected craft version ${version}, received: ${output}`)
31 }
32 console.log(`Verified ${binary}${execute ? ` launches as ${version}` : ' signature and entitlements'}`)
33}
34
35if (import.meta.main) {
36 const [binary, version, option] = Bun.argv.slice(2)
37 if (!binary || !version || (option !== undefined && option !== '--no-execute'))
38 throw new Error('Usage: bun scripts/verify-macos-release.ts <binary> <version> [--no-execute]')
39 verifyMacosRelease(resolve(binary), version, option !== '--no-execute')
40}