#!/bin/bash set -euo pipefail # Helper imports must not write bytecode caches outside the signing roots. export PYTHONDONTWRITEBYTECODE=1 APP_BUNDLE="dist/OpenClaw.app" SIGNING_VARIANT="${OPENCLAW_MAC_SIGNING_VARIANT:-standard}" source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/mac-signing-identity.sh" ELEVATION_TEAM_ID="FWJYW4S8P8" TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}" CODESIGN_TIMESTAMP_RETRY_ATTEMPTS="${CODESIGN_TIMESTAMP_RETRY_ATTEMPTS:-8}" CODESIGN_TIMESTAMP_RETRY_DELAY_SECONDS="${CODESIGN_TIMESTAMP_RETRY_DELAY_SECONDS:-5}" DISABLE_LIBRARY_VALIDATION="${DISABLE_LIBRARY_VALIDATION:-0}" SKIP_TEAM_ID_CHECK="${SKIP_TEAM_ID_CHECK:-0}" ENT_TMP_DIR="" cleanup() { if [[ -n "$ENT_TMP_DIR" ]]; then rm -rf "$ENT_TMP_DIR" fi } if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then cat <<'HELP' Usage: scripts/codesign-mac-app.sh [app-bundle] Env: SIGN_IDENTITY="Apple Development: Your Name (TEAMID)" OPENCLAW_MAC_SIGNING_VARIANT=standard|elevation-host ALLOW_ADHOC_SIGNING=1 CODESIGN_TIMESTAMP=auto|on|off CODESIGN_TIMESTAMP_RETRY_ATTEMPTS=8 CODESIGN_TIMESTAMP_RETRY_DELAY_SECONDS=5 DISABLE_LIBRARY_VALIDATION=1 # dev-only Sparkle Team ID workaround SKIP_TEAM_ID_CHECK=1 # bypass Team ID audit HELP exit 0 fi case "$SIGNING_VARIANT" in standard|elevation-host) ;; *) echo "ERROR: Unknown OPENCLAW_MAC_SIGNING_VARIANT value: $SIGNING_VARIANT (use standard|elevation-host)" >&2 exit 1 ;; esac if [[ "$SIGNING_VARIANT" == "elevation-host" && "$DISABLE_LIBRARY_VALIDATION" == "1" ]]; then echo "ERROR: Elevation host signing forbids DISABLE_LIBRARY_VALIDATION=1." >&2 exit 1 fi if [[ "$SIGNING_VARIANT" == "elevation-host" && "$SKIP_TEAM_ID_CHECK" == "1" ]]; then echo "ERROR: Elevation host signing forbids SKIP_TEAM_ID_CHECK=1." >&2 exit 1 fi if [[ "${1:-}" == "--" ]]; then shift fi if [[ "$#" -gt 0 ]]; then case "$1" in -*) echo "ERROR: Unknown codesign option: $1" >&2; exit 1 ;; *) APP_BUNDLE="$1"; shift ;; esac fi if [[ "$#" -gt 0 ]]; then echo "ERROR: Unexpected codesign argument: $1" >&2 exit 1 fi # Match scanner paths and expose symlink roots before any validation or signing. while [[ "$APP_BUNDLE" == */ && "$APP_BUNDLE" != "/" ]]; do APP_BUNDLE="${APP_BUNDLE%/}" done if [ ! -d "$APP_BUNDLE" ]; then echo "App bundle not found: $APP_BUNDLE" >&2 exit 1 fi # A non-following inventory cannot audit a symlink used as the bundle root. if [[ -L "$APP_BUNDLE" ]]; then echo "ERROR: App bundle must not be a symlink: $APP_BUNDLE" >&2 exit 1 fi # Freeze the physical policy root now; resolving it after a swap could authorize the replacement. APP_MUTATION_ROOT="$(cd -P -- "$APP_BUNDLE" && pwd -P)" IDENTITY="$(resolve_mac_signing_identity)" echo "Using signing identity: $IDENTITY" if [[ "$IDENTITY" == "-" ]]; then cat <<'WARN' >&2 ================================================================================ !!! AD-HOC SIGNING IN USE - PERMISSIONS WILL NOT STICK (macOS RESTRICTION) !!! macOS ties permissions to the code signature, bundle ID, and app path. Ad-hoc signing generates a new signature every build, so macOS treats the app as a different binary and will forget permissions (prompts may vanish). For correct permission behavior you MUST sign with a real Apple Development or Developer ID certificate. If prompts disappear: remove the app entry in System Settings -> Privacy & Security, relaunch the app, and re-grant. Some permissions only reappear after a full macOS restart. ================================================================================ WARN fi timestamp_arg="--timestamp=none" case "$TIMESTAMP_MODE" in 1|on|yes|true) timestamp_arg="--timestamp" ;; 0|off|no|false) timestamp_arg="--timestamp=none" ;; auto) identity_name="$IDENTITY" if [[ "$IDENTITY" =~ ^[[:xdigit:]]{40}$ ]]; then # A hash selector conceals the certificate class required for notarization timestamps. identity_name="$(security find-identity -p codesigning -v 2>/dev/null \ | awk -v hash="$IDENTITY" 'toupper($2) == toupper(hash) { print }')" fi if [[ "$identity_name" == *"Developer ID Application"* ]]; then timestamp_arg="--timestamp" fi ;; *) echo "ERROR: Unknown CODESIGN_TIMESTAMP value: $TIMESTAMP_MODE (use auto|on|off)" >&2 exit 1 ;; esac if [[ "$IDENTITY" == "-" ]]; then timestamp_arg="--timestamp=none" fi if [[ ! "$CODESIGN_TIMESTAMP_RETRY_ATTEMPTS" =~ ^[1-9][0-9]*$ ]]; then echo "ERROR: CODESIGN_TIMESTAMP_RETRY_ATTEMPTS must be a positive integer" >&2 exit 1 fi if [[ ! "$CODESIGN_TIMESTAMP_RETRY_DELAY_SECONDS" =~ ^[0-9]+$ ]]; then echo "ERROR: CODESIGN_TIMESTAMP_RETRY_DELAY_SECONDS must be a nonnegative integer" >&2 exit 1 fi ENT_TMP_DIR=$(mktemp -d -t openclaw-entitlements.XXXXXX) trap cleanup EXIT ENT_TMP_DIR="$(cd -P -- "$ENT_TMP_DIR" && pwd -P)" ENT_TMP_APP="$ENT_TMP_DIR/app.plist" ENT_TMP_JIT="$ENT_TMP_DIR/jit.plist" ENT_TMP_BUN="$ENT_TMP_DIR/bun.plist" CODESIGN_OUTPUT="$ENT_TMP_DIR/codesign-output" NATIVE_INVENTORY="$ENT_TMP_DIR/native-inventory" INVENTORY_SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/mac-native-inventory.py" MUTATION_SCRIPT="${INVENTORY_SCRIPT%/*}/mac-bundle-mutation.py" options_args=() if [[ "$IDENTITY" != "-" ]]; then options_args=("--options" "runtime") fi timestamp_args=("$timestamp_arg") if [[ "$SIGNING_VARIANT" == "elevation-host" ]]; then cat > "$ENT_TMP_APP" <<'PLIST' PLIST else cat > "$ENT_TMP_APP" <<'PLIST' com.apple.security.automation.apple-events com.apple.security.device.audio-input com.apple.security.device.camera com.apple.security.personal-information.location PLIST fi if [[ "$DISABLE_LIBRARY_VALIDATION" == "1" ]]; then /usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP" >/dev/null 2>&1 || \ /usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP" echo "Note: disable-library-validation entitlement enabled (DISABLE_LIBRARY_VALIDATION=1)." fi APP_ENTITLEMENTS="$ENT_TMP_APP" # The Claude SDK's standalone CLI needs JIT memory but retains library validation. cat > "$ENT_TMP_JIT" <<'PLIST' com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory PLIST # Bun executes arbitrary plugin JS and must load its non-Team-signed native addons. # Keep this exception on the private runtime; bundled natives are still Team-signed. cat > "$ENT_TMP_BUN" <<'PLIST' com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.disable-library-validation PLIST run_bundle_mutation() { /usr/bin/python3 "$MUTATION_SCRIPT" "$APP_MUTATION_ROOT" "$ENT_TMP_DIR" "$@" } codesign_with_timestamp_retry() { local attempt=1 local command_rc local delay while true; do : >"$CODESIGN_OUTPUT" command_rc=0 run_bundle_mutation codesign "$@" >"$CODESIGN_OUTPUT" 2>&1 || command_rc=$? cat "$CODESIGN_OUTPUT" >&2 if [[ "$command_rc" -eq 0 ]]; then return 0 fi if [[ "$timestamp_arg" != "--timestamp" ]] || ! grep -Eiq 'A timestamp was expected but was not found|timestamp service is not available' "$CODESIGN_OUTPUT" then return "$command_rc" fi if [[ "$attempt" -ge "$CODESIGN_TIMESTAMP_RETRY_ATTEMPTS" ]]; then echo "codesign timestamp retry limit reached after $attempt attempts" >&2 return "$command_rc" fi delay=$((CODESIGN_TIMESTAMP_RETRY_DELAY_SECONDS * attempt)) ((delay <= 30)) || delay=30 echo "Transient Apple timestamp failure; retrying codesign in ${delay}s (attempt $((attempt + 1))/$CODESIGN_TIMESTAMP_RETRY_ATTEMPTS)" >&2 sleep "$delay" attempt=$((attempt + 1)) done } sign_item() { local target="$1" local entitlements="$2" codesign_with_timestamp_retry --force ${options_args+"${options_args[@]}"} "${timestamp_args[@]}" --entitlements "$entitlements" --sign "$IDENTITY" "$target" } sign_plain_item() { local target="$1" codesign_with_timestamp_retry --force ${options_args+"${options_args[@]}"} "${timestamp_args[@]}" --sign "$IDENTITY" "$target" } codesign_metadata_value() { local target="$1" key="$2" metadata metadata="$(codesign -dv --verbose=4 "$target" 2>&1)" || { local rc=$? echo "ERROR: Could not read codesign metadata: $target" >&2 return "$rc" } # Executable/Identifier can contain filename newlines. The real CodeDirectory # follows Format and resets any injected fields; Authority starts with the leaf. awk -F= -v key="$key" ' /^CodeDirectory / { format = previous; value = ""; found = 0 } $1 == key && (!found || key != "Authority") { value = substr($0, length(key) + 2); found = 1 } { previous = $0 } END { if (format !~ /^Format=.*Mach-O /) exit 1 print value } ' <<<"$metadata" || { echo "ERROR: Native Mach-O signature missing: $target. Rebuild the payload as codesign-compatible native code (thin/fat32); generic signatures are not accepted." >&2 return 1 } } team_id_for() { codesign_metadata_value "$1" TeamIdentifier } verify_native_signatures() { local expected expected="$(team_id_for "$APP_BUNDLE")" # A Team ID opt-out never permits generic signatures on native candidates. if [[ "$SKIP_TEAM_ID_CHECK" == "1" ]]; then echo "Note: skipping Team ID comparison (SKIP_TEAM_ID_CHECK=1); native format checks remain enabled." elif [[ -z "$expected" ]]; then echo "ERROR: TeamIdentifier missing on app bundle." >&2 return 1 fi local mismatches=() kind f team while IFS= read -r -d '' kind && IFS= read -r -d '' f; do [[ "$kind" == "executable" || "$kind" == "library" ]] || continue team="$(team_id_for "$f")" if [[ "$SKIP_TEAM_ID_CHECK" != "1" && ( -z "$team" || "$team" != "$expected" ) ]]; then mismatches+=("$f (TeamIdentifier=${team:-missing})") fi done < "$NATIVE_INVENTORY" if [[ "${#mismatches[@]}" -gt 0 ]]; then echo "ERROR: Team ID mismatch detected (expected: $expected)" for entry in "${mismatches[@]}"; do echo " - $entry" done echo "Hint: re-sign embedded frameworks or set DISABLE_LIBRARY_VALIDATION=1 for dev builds." exit 1 fi } assert_no_elevation_cua_driver() { [[ "$SIGNING_VARIANT" == "elevation-host" ]] || return 0 local cua_driver="$APP_BUNDLE/Contents/Resources/cua-driver" if [[ -e "$cua_driver" || -L "$cua_driver" ]]; then echo "ERROR: Elevation host must not contain bundled CUA driver: $cua_driver" >&2 exit 1 fi } # Sign-time twin of verify_elevation_app in mac-elevation-host.sh, which asserts the same identity # invariants but requires an already notarized and stapled bundle. Dropping this check defers every # elevation identity failure until after an Apple notarization submission has been spent. verify_elevation_signature() { [[ "$SIGNING_VARIANT" == "elevation-host" ]] || return 0 assert_no_elevation_cua_driver local actual_team actual_team="$(team_id_for "$APP_BUNDLE")" if [[ "$actual_team" != "$ELEVATION_TEAM_ID" ]]; then echo "ERROR: Elevation host requires TeamIdentifier=$ELEVATION_TEAM_ID, got '${actual_team:-not set}'." >&2 exit 1 fi local authority authority="$(codesign_metadata_value "$APP_BUNDLE" Authority)" if [[ "$authority" != "$ELEVATION_IDENTITY" ]]; then echo "ERROR: Elevation host requires '$ELEVATION_IDENTITY', got '${authority:-not set}'." >&2 exit 1 fi assert_no_apple_events_entitlement() { local signed_path="$1" local entitlements entitlements="$(codesign -d --entitlements :- "$signed_path")" if /usr/bin/grep -q "com.apple.security.automation.apple-events" <<<"$entitlements"; then echo "ERROR: Elevation host code retains Apple Events entitlement: $signed_path" >&2 exit 1 fi } assert_no_apple_events_entitlement "$APP_BUNDLE" local kind signed_path while IFS= read -r -d '' kind && IFS= read -r -d '' signed_path; do [[ "$kind" == "executable" || "$kind" == "library" ]] || continue assert_no_apple_events_entitlement "$signed_path" done < "$NATIVE_INVENTORY" } # Complete the scan before any signing; process substitution hides scanner failures. assert_no_elevation_cua_driver /usr/bin/python3 "$INVENTORY_SCRIPT" "$APP_BUNDLE" > "$NATIVE_INVENTORY" # Shared inodes escape path confinement; special files can block recursive xattr. find "$APP_BUNDLE" \( -type f -links +1 -o ! -type f ! -type d ! -type l \) -print0 > "$ENT_TMP_DIR/unsafe-inputs" if [[ -s "$ENT_TMP_DIR/unsafe-inputs" ]]; then echo "ERROR: Signing requires a private app copy without hardlinked or special files; copy or rebuild the bundle first." >&2 exit 1 fi # Clear attributes only after input validation, inside the same write boundary as signing. run_bundle_mutation xattr -cr "$APP_BUNDLE" 2>/dev/null || true # Sign bundled helper binaries before signing the app bundle. MAC_CONTROL_CLI="$APP_BUNDLE/Contents/MacOS/openclaw-mac" if [ -f "$MAC_CONTROL_CLI" ]; then echo "Signing macOS control CLI"; sign_plain_item "$MAC_CONTROL_CLI" fi MLX_TTS_HELPER="$APP_BUNDLE/Contents/MacOS/openclaw-mlx-tts" if [ -f "$MLX_TTS_HELPER" ]; then echo "Signing MLX TTS helper"; sign_plain_item "$MLX_TTS_HELPER" fi CUA_DRIVER="$APP_BUNDLE/Contents/Resources/cua-driver" if [ -f "$CUA_DRIVER" ]; then echo "Signing embedded CUA driver"; sign_plain_item "$CUA_DRIVER" fi while IFS= read -r -d '' helper_kind && IFS= read -r -d '' helper_file; do [[ "$helper_kind" == "executable" ]] || continue [[ "$helper_file" == "$APP_BUNDLE/Contents/Resources/cloudflared/"* ]] || continue sign_plain_item "$helper_file" codesign --verify --strict "$helper_file" done < "$NATIVE_INVENTORY" # Seal all native payloads before the enclosing app; npm packages can carry # standalone executables and addons below arbitrarily nested dependency roots. RUNTIME_ROOT="$APP_BUNDLE/Contents/Resources/runtime" while IFS= read -r -d '' runtime_kind && IFS= read -r -d '' runtime_file; do [[ "$runtime_kind" == "executable" || "$runtime_kind" == "library" ]] || continue [[ "$runtime_file" == "$RUNTIME_ROOT/"* ]] || continue runtime_relative="${runtime_file#"$RUNTIME_ROOT"/}" # Only the private Bun runtime hosts arbitrary installed plugin native addons. if [[ "$runtime_kind" == "executable" && "$runtime_relative" == bin/bun ]]; then sign_item "$runtime_file" "$ENT_TMP_BUN" elif [[ "$runtime_kind" == "executable" && ( "$runtime_relative" == */node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64/claude || "$runtime_relative" == */node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64/claude ) ]]; then sign_item "$runtime_file" "$ENT_TMP_JIT" else sign_plain_item "$runtime_file" fi codesign --verify --strict "$runtime_file" done < "$NATIVE_INVENTORY" # Sign Sparkle deeply if present SPARKLE="$APP_BUNDLE/Contents/Frameworks/Sparkle.framework" if [ -d "$SPARKLE" ]; then echo "Signing Sparkle framework and helpers" while IFS= read -r -d '' kind && IFS= read -r -d '' f; do [[ "$kind" == "executable" || "$kind" == "library" ]] || continue [[ "$f" == "$SPARKLE/"* ]] || continue # Known Sparkle entry points are signed explicitly below. codesign treats # bundle main-executable paths as the whole bundle, so don't seal them early. case "${f#"$SPARKLE/Versions/B/"}" in Sparkle|Autoupdate|Updater.app/Contents/MacOS/Updater|\ XPCServices/Downloader.xpc/Contents/MacOS/Downloader|\ XPCServices/Installer.xpc/Contents/MacOS/Installer) continue ;; esac sign_plain_item "$f" done < "$NATIVE_INVENTORY" sign_plain_item "$SPARKLE/Versions/B/Autoupdate" sign_plain_item "$SPARKLE/Versions/B/Updater.app" sign_plain_item "$SPARKLE/Versions/B/XPCServices/Downloader.xpc" sign_plain_item "$SPARKLE/Versions/B/XPCServices/Installer.xpc" sign_plain_item "$SPARKLE" fi # Sign any other embedded frameworks/dylibs if [ -d "$APP_BUNDLE/Contents/Frameworks" ]; then find "$APP_BUNDLE/Contents/Frameworks" -depth \( -name "*.framework" -o -name "*.dylib" \) ! -path "*Sparkle.framework*" -print0 > "$ENT_TMP_DIR/frameworks" while IFS= read -r -d '' f; do echo "Signing framework: $f"; sign_plain_item "$f" done < "$ENT_TMP_DIR/frameworks" fi # The bundle seal also signs its main executable. Signing that path separately # would seal every resource twice, with the first seal preceding nested code. sign_item "$APP_BUNDLE" "$APP_ENTITLEMENTS" # Signing can create files. Rebuild after the final seal; the two read-only # audits share only this completed post-sign inventory, never the pre-sign scan. /usr/bin/python3 "$INVENTORY_SCRIPT" "$APP_BUNDLE" > "$NATIVE_INVENTORY" verify_native_signatures verify_elevation_signature echo "Codesign complete for $APP_BUNDLE"