"""Deterministic Apple-signed Reyna CLI.app bundle builder via Xcode. Bundle layout: /native/ReynaCLIHost/dist/Reyna CLI.app/ Contents/ Info.plist (bound, deterministic, CFBundleIdentifier=com.reyna.cli.privacy-host) MacOS/ ReynaCLIHost (executable, 0755, copied atomically) Security: - Xcode owns signing; no manual `codesign --sign` invocation. - Real build command is xcodebuild with Automatic Signing (CODE_SIGN_STYLE=Automatic in project). - For CI/test unsigned verification, caller may disable signing via CODE_SIGNING_ALLOWED=NO. - Validation remains fail-closed: non-adhoc signature, team present, bound Info plist, fixed bundle identifier. - All subprocess invocations use arg arrays, never shell. - No secret material logged. """ from __future__ import annotations import os import plistlib import stat import shutil import subprocess import uuid from pathlib import Path from typing import Any, Callable, Dict, List, Optional BUNDLE_IDENTIFIER = "com.reyna.cli.privacy-host" APP_BUNDLE_NAME = "Reyna CLI.app" APP_EXECUTABLE_NAME = "ReynaCLIHost" BUNDLE_VERSION = "1" BUNDLE_SHORT_VERSION = "1.0.0" XCODE_PROJECT_REL = Path("native") / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj" XCODE_TARGET_NAME = "Reyna CLI" # deprecated alias; use SCHEME for valid -derivedDataPath builds XCODE_SCHEME_NAME = "Reyna CLI" XCODE_CONFIGURATION = "Release" XCODE_INFO_PLIST_REL = Path("native") / "ReynaCLIHost" / "ReynaCLIHost" / "Info.plist" DERIVED_DATA_REL = Path("native") / "ReynaCLIHost" / "build" / "DerivedData" SIGNING_IDENTITY_ENV_VAR = "REYNA_CLI_SIGNING_IDENTITY" # deprecated, kept for compat; no longer required def _repo_root() -> Path: return Path(__file__).resolve().parents[2] def _package_dir(repo_root: Optional[Path] = None) -> Path: r = repo_root or _repo_root() return r / "native" / "ReynaCLIHost" def _xcodeproj_path(repo_root: Optional[Path] = None) -> Path: r = repo_root or _repo_root() return r / XCODE_PROJECT_REL def _info_plist_source_path(repo_root: Optional[Path] = None) -> Path: r = repo_root or _repo_root() return r / XCODE_INFO_PLIST_REL def _derived_data_path(repo_root: Optional[Path] = None, override: Optional[Path] = None) -> Path: if override is not None: return Path(override) r = repo_root or _repo_root() return r / DERIVED_DATA_REL def _built_product_app_path(derived_data_path: Path) -> Path: return derived_data_path / "Build" / "Products" / XCODE_CONFIGURATION / APP_BUNDLE_NAME def app_bundle_dir(repo_root: Optional[Path] = None) -> Path: r = repo_root or _repo_root() return r / "native" / "ReynaCLIHost" / "dist" def app_bundle_path(repo_root: Optional[Path] = None) -> Path: return app_bundle_dir(repo_root) / APP_BUNDLE_NAME def app_bundle_info_plist_path(repo_root: Optional[Path] = None) -> Path: return app_bundle_path(repo_root) / "Contents" / "Info.plist" def app_bundle_executable_path(repo_root: Optional[Path] = None) -> Path: return app_bundle_path(repo_root) / "Contents" / "MacOS" / APP_EXECUTABLE_NAME def build_app_bundle_info_plist_dict() -> Dict[str, Any]: """Deterministic Info.plist dict, sorted keys for reproducibility.""" return { "CFBundleDevelopmentRegion": "en", "CFBundleDisplayName": "Reyna CLI", "CFBundleExecutable": APP_EXECUTABLE_NAME, "CFBundleIdentifier": BUNDLE_IDENTIFIER, "CFBundleInfoDictionaryVersion": "6.0", "CFBundleName": "Reyna CLI", "CFBundlePackageType": "APPL", "CFBundleShortVersionString": BUNDLE_SHORT_VERSION, "CFBundleVersion": BUNDLE_VERSION, "LSMinimumSystemVersion": "13.0", "NSCalendarsFullAccessUsageDescription": "Reyna CLI needs calendar access to list and manage your events locally.", "NSContactsUsageDescription": "Reyna CLI needs contacts access to search and manage your contacts locally.", "NSRemindersFullAccessUsageDescription": "Reyna CLI needs reminders access to list and manage your reminders locally.", "LSUIElement": True, } def _default_runner(args: List[str], cwd: Optional[Path] = None, **kwargs: Any): return subprocess.run( args, capture_output=True, text=True, check=False, cwd=str(cwd) if cwd is not None else None, **kwargs, ) def _ensure_dir_mode(path: Path, mode: int) -> None: try: st_l = path.lstat() if stat.S_ISLNK(st_l.st_mode): raise ValueError(f"refusing symlink directory: {path}") if not stat.S_ISDIR(st_l.st_mode): raise ValueError(f"path exists and is not a directory: {path}") os.chmod(path, mode) st = path.stat() if stat.S_IMODE(st.st_mode) != mode: raise PermissionError(f"directory mode {oct(stat.S_IMODE(st.st_mode))} != {oct(mode)}: {path}") return except FileNotFoundError: pass path.mkdir(parents=True, exist_ok=True, mode=mode) os.chmod(path, mode) st_l = path.lstat() if stat.S_ISLNK(st_l.st_mode): raise ValueError(f"refusing symlink dir after creation: {path}") if not stat.S_ISDIR(st_l.st_mode): raise ValueError(f"path not dir after creation: {path}") st = path.stat() if stat.S_IMODE(st.st_mode) != mode: os.chmod(path, mode) st = path.stat() if stat.S_IMODE(st.st_mode) != mode: raise PermissionError(f"directory mode {oct(stat.S_IMODE(st.st_mode))} != {oct(mode)} after chmod: {path}") def _copy_app_bundle_atomic(src: Path, dst: Path) -> None: """Atomically copy .app bundle from src to dst.""" if not src.exists(): raise FileNotFoundError(f"source bundle not found: {src}") try: s_l = src.lstat() if stat.S_ISLNK(s_l.st_mode): raise ValueError(f"refusing symlink source bundle: {src}") if not stat.S_ISDIR(s_l.st_mode): raise ValueError(f"source bundle not a directory: {src}") except FileNotFoundError: raise parent = dst.parent _ensure_dir_mode(parent, 0o700) tmp_name = f".{dst.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}" tmp_path = parent / tmp_name try: if tmp_path.exists(): if tmp_path.is_dir(): shutil.rmtree(str(tmp_path)) else: tmp_path.unlink() shutil.copytree(str(src), str(tmp_path), symlinks=False) tst = tmp_path.lstat() if stat.S_ISLNK(tst.st_mode): raise ValueError(f"temp dst is symlink: {tmp_path}") if not stat.S_ISDIR(tst.st_mode): raise ValueError(f"temp dst not dir: {tmp_path}") if dst.exists(): dl = dst.lstat() if stat.S_ISLNK(dl.st_mode): raise ValueError(f"refusing to replace symlinked dst: {dst}") if dst.is_dir(): shutil.rmtree(str(dst)) else: dst.unlink() os.replace(str(tmp_path), str(dst)) final_st = dst.lstat() if stat.S_ISLNK(final_st.st_mode): raise ValueError(f"final dst is symlink: {dst}") finally: try: if tmp_path.exists(): if tmp_path.is_dir(): shutil.rmtree(str(tmp_path)) else: tmp_path.unlink() except Exception: pass def build_xcodebuild_command( repo_root: Optional[Path] = None, derived_data_path: Optional[Path] = None, disable_code_signing: bool = False, ) -> List[str]: """Return safe arg-array xcodebuild command. This is the canonical build command; Xcode owns signing (Automatic). Uses -scheme (shared, committed) with -derivedDataPath which requires scheme. When disable_code_signing is True, adds CODE_SIGNING_ALLOWED=NO for unsigned verification. """ r = repo_root or _repo_root() proj = _xcodeproj_path(r) dd = _derived_data_path(r, override=derived_data_path) cmd: List[str] = [ "xcodebuild", "-project", str(proj), "-scheme", XCODE_SCHEME_NAME, "-configuration", XCODE_CONFIGURATION, "-derivedDataPath", str(dd), "build", ] if disable_code_signing: cmd.append("CODE_SIGNING_ALLOWED=NO") return cmd # Backward compat: old RELEASE_BUILD_ARGS now points to xcodebuild with default derived path # (callers should use build_xcodebuild_command for testability). def _default_xcodebuild_args_for_compat() -> List[str]: return build_xcodebuild_command() RELEASE_BUILD_ARGS: List[str] = _default_xcodebuild_args_for_compat() def _resolve_signing_identity(explicit: Optional[str]) -> Optional[str]: """Deprecated: signing identity no longer required for Automatic Signing. Kept for backwards compatibility; returns identity if provided, else env var if set. """ if explicit is not None: s = str(explicit).strip() if s: return s return None env_val = os.environ.get(SIGNING_IDENTITY_ENV_VAR) if env_val is None: return None s = str(env_val).strip() if not s: return None return s def _defense_check_source_app_path(p: Path) -> Optional[str]: """Defend source .app path: absolute, .app suffix, dir, not symlink, no traversal tricks.""" try: s = str(p) except Exception: return "invalid app bundle path" # Reject empty if not s: return "empty app bundle path" # Must be absolute if not p.is_absolute(): return f"app bundle path must be absolute: {p}" # Must end with .app if not s.endswith(".app"): return f"app bundle path must end with .app suffix: {p}" # Reject if contains .. components to avoid traversal sneaks (even though absolute) # Use Path parts check: if \"..\" in parts if ".." in Path(s).parts: return f"app bundle path must not contain '..': {p}" try: st = p.lstat() except FileNotFoundError: return f"source bundle not found: {p}" except Exception as exc: return f"source bundle lstat failed: {exc}" if stat.S_ISLNK(st.st_mode): return f"refusing symlink source bundle: {p}" if not stat.S_ISDIR(st.st_mode): return f"source bundle is not a directory: {p}" return None def _validate_bundle_at_paths( bundle_path: Path, exe_path: Path, plist_path: Path, runner: Optional[Callable[..., Any]] = None, ) -> Dict[str, Any]: """Core validation logic parameterized by explicit paths. No secret leakage.""" run_fn = runner or _default_runner result: Dict[str, Any] = { "ok": False, "app_bundle_path": str(bundle_path), "bundle_identifier": BUNDLE_IDENTIFIER, "bundle_identifier_expected": BUNDLE_IDENTIFIER, "executable_path": str(exe_path), "info_plist_path": str(plist_path), "bundle_exists": False, "executable_exists": False, "info_plist_exists": False, "bundle_identifier_matches": False, "signature_verified": False, "is_ad_hoc": None, "errors": [], } try: result["bundle_exists"] = bundle_path.exists() result["executable_exists"] = exe_path.exists() result["info_plist_exists"] = plist_path.exists() except Exception as exc: result["errors"].append(f"existence check failed: {exc}") if not result["bundle_exists"]: result["errors"].append(f"bundle missing at {bundle_path}") return result if not result["executable_exists"]: result["errors"].append(f"executable missing at {exe_path}") return result if not result["info_plist_exists"]: result["errors"].append(f"Info.plist missing at {plist_path}") return result try: with open(plist_path, "rb") as f: d = plistlib.load(f) bid = d.get("CFBundleIdentifier") result["bundle_identifier_found"] = bid if bid == BUNDLE_IDENTIFIER: result["bundle_identifier_matches"] = True else: result["errors"].append(f"bundle identifier mismatch: expected {BUNDLE_IDENTIFIER} got {bid}") return result bexe = d.get("CFBundleExecutable") if bexe != APP_EXECUTABLE_NAME: result["errors"].append(f"CFBundleExecutable mismatch: expected {APP_EXECUTABLE_NAME} got {bexe}") return result if "NSCalendarsFullAccessUsageDescription" not in d: result["errors"].append("missing NSCalendarsFullAccessUsageDescription") return result if "NSContactsUsageDescription" not in d: result["errors"].append("missing NSContactsUsageDescription") return result if "NSRemindersFullAccessUsageDescription" not in d: result["errors"].append("missing NSRemindersFullAccessUsageDescription") return result # Notes (AppleEvents) deliberately deferred — must NOT be required # Forbid AppleEvents / Notes usage description if "NSAppleEventsUsageDescription" in d: result["errors"].append("forbidden usage description present: NSAppleEventsUsageDescription (Notes deferred)") return result forbidden_keys = [ "NSRemindersUsageDescription", ] for fk in forbidden_keys: if fk in d: result["errors"].append(f"forbidden usage description present: {fk}") return result allowed_usage_keys = { "NSCalendarsFullAccessUsageDescription", "NSCalendarsWriteOnlyAccessUsageDescription", "NSCalendarsUsageDescription", "NSContactsUsageDescription", "NSRemindersFullAccessUsageDescription", } for k in d.keys(): if k.startswith("NS") and "UsageDescription" in k: if k not in allowed_usage_keys: result["errors"].append(f"unexpected usage description key: {k}") return result except Exception as exc: result["errors"].append(f"Info.plist read/validation failed: {exc}") return result try: proc = run_fn(["codesign", "--verify", "--deep", "--strict", str(bundle_path)]) rc = getattr(proc, "returncode", -1) if rc == 0: result["signature_verified"] = True else: result["signature_verified"] = False # Do not leak raw codesign identity output; truncate generic message result["errors"].append(f"codesign verify failed rc={rc}") return result except Exception as exc: result["errors"].append(f"codesign verify exception: {exc}") return result try: proc2 = run_fn(["codesign", "-dv", str(bundle_path)]) stderr = (getattr(proc2, "stderr", "") or "") + (getattr(proc2, "stdout", "") or "") lower = stderr.lower() is_ad_hoc = False if "signature=adhoc" in lower: is_ad_hoc = True if "teamidentifier=not set" in lower: is_ad_hoc = True result["is_ad_hoc"] = is_ad_hoc if is_ad_hoc: result["errors"].append("bundle is ad-hoc signed (TeamIdentifier not set) - stable TCC identity required") result["signature_verified"] = False return result except Exception as exc: result["errors"].append(f"ad-hoc check failed: {exc}") result["signature_verified"] = False return result result["ok"] = True return result def validate_app_bundle( repo_root: Optional[Path] = None, runner: Optional[Callable[..., Any]] = None, ) -> Dict[str, Any]: """Validate bundle layout and signature without logging secrets (dist location).""" r = repo_root or _repo_root() bundle = app_bundle_path(r) exe = app_bundle_executable_path(r) plist_p = app_bundle_info_plist_path(r) return _validate_bundle_at_paths(bundle, exe, plist_p, runner=runner) def validate_app_bundle_at_path( bundle_path: Path, runner: Optional[Callable[..., Any]] = None, ) -> Dict[str, Any]: """Validate an arbitrary source .app bundle path (absolute, defended) using full signed validation.""" bp = Path(bundle_path) # Basic defense (no copy, just validation): must be absolute .app dir, not symlink err = _defense_check_source_app_path(bp) if err: return { "ok": False, "app_bundle_path": str(bp), "bundle_identifier": BUNDLE_IDENTIFIER, "bundle_identifier_expected": BUNDLE_IDENTIFIER, "bundle_exists": False, "signature_verified": False, "is_ad_hoc": None, "errors": [err], } exe = bp / "Contents" / "MacOS" / APP_EXECUTABLE_NAME plist_p = bp / "Contents" / "Info.plist" return _validate_bundle_at_paths(bp, exe, plist_p, runner=runner) def install_prebuilt_app_bundle( source_bundle_path: Path, repo_root: Optional[Path] = None, runner: Optional[Callable[..., Any]] = None, ) -> Dict[str, Any]: """Secure prebuilt install: validate source, atomic copy to dist, validate copy. No xcodebuild.""" run_fn = runner or _default_runner r = repo_root or _repo_root() src = Path(source_bundle_path) def_err = _defense_check_source_app_path(src) if def_err: return { "ok": False, "action": "install_prebuilt_app_bundle", "error": def_err, "source_bundle_path": str(src), "app_bundle_path": str(app_bundle_path(r)), "bundle_identifier": BUNDLE_IDENTIFIER, } # Validate source before any copy src_validation = validate_app_bundle_at_path(src, runner=run_fn) if not src_validation.get("ok"): return { "ok": False, "action": "install_prebuilt_app_bundle", "error": f"source bundle validation failed: {'; '.join(src_validation.get('errors', []))}", "validation": src_validation, "source_bundle_path": str(src), "app_bundle_path": str(app_bundle_path(r)), "bundle_identifier": BUNDLE_IDENTIFIER, } dist_bundle = app_bundle_path(r) try: dist_dir = app_bundle_dir(r) _ensure_dir_mode(dist_dir, 0o700) _copy_app_bundle_atomic(src, dist_bundle) except Exception as exc: return { "ok": False, "action": "install_prebuilt_app_bundle", "error": f"bundle copy failed: {exc}", "source_bundle_path": str(src), "app_bundle_path": str(dist_bundle), "bundle_identifier": BUNDLE_IDENTIFIER, } # Validate copy dst_validation = validate_app_bundle(repo_root=r, runner=run_fn) if not dst_validation.get("ok"): return { "ok": False, "action": "install_prebuilt_app_bundle", "error": f"copied bundle validation failed: {'; '.join(dst_validation.get('errors', []))}", "validation": dst_validation, "source_validation": src_validation, "source_bundle_path": str(src), "app_bundle_path": str(dist_bundle), "bundle_identifier": BUNDLE_IDENTIFIER, } return { "ok": True, "action": "install_prebuilt_app_bundle", "app_bundle_path": str(dist_bundle), "bundle_identifier": BUNDLE_IDENTIFIER, "executable_path": str(app_bundle_executable_path(r)), "info_plist_path": str(app_bundle_info_plist_path(r)), "signature_verified": True, "validation": {k: v for k, v in dst_validation.items() if k != "errors" or v}, "source_validation": {k: v for k, v in src_validation.items() if k != "errors" or v}, "source_bundle_path": str(src), } def build_app_bundle( signing_identity: Optional[str] = None, repo_root: Optional[Path] = None, runner: Optional[Callable[..., Any]] = None, disable_code_signing: bool = False, derived_data_path_override: Optional[Path] = None, ) -> Dict[str, Any]: """Build deterministic Reyna CLI.app bundle via xcodebuild. Steps: 1. Compute xcodeproject path and controlled DerivedData path. 2. Run xcodebuild -project -scheme 'Reyna CLI' -configuration Release -derivedDataPath build (with CODE_SIGNING_ALLOWED=NO when disable_code_signing=True for unsigned verification). Xcode owns signing via Automatic Signing; no manual `codesign --sign`. 3. Locate product app in DerivedData/Build/Products/Release/Reyna CLI.app 4. Safely copy it to native/ReynaCLIHost/dist/Reyna CLI.app 5. Validate final bundle (identifier, not ad-hoc when signed). signing_identity arg is deprecated and ignored for Automatic Signing; kept for compat. """ run_fn = runner or _default_runner r = repo_root or _repo_root() proj_path = _xcodeproj_path(r) derived_path = _derived_data_path(r, override=derived_data_path_override) built_product = _built_product_app_path(derived_path) dist_bundle = app_bundle_path(r) exe_dst = app_bundle_executable_path(r) plist_dst = app_bundle_info_plist_path(r) if not proj_path.exists(): return { "ok": False, "action": "build_app_bundle", "error": f"xcodeproj not found at {proj_path}", "app_bundle_path": str(dist_bundle), "bundle_identifier": BUNDLE_IDENTIFIER, } info_src = _info_plist_source_path(r) if not info_src.exists(): return { "ok": False, "action": "build_app_bundle", "error": f"Info.plist source not found at {info_src}", "app_bundle_path": str(dist_bundle), "bundle_identifier": BUNDLE_IDENTIFIER, } build_cmd = build_xcodebuild_command( repo_root=r, derived_data_path=derived_path, disable_code_signing=disable_code_signing, ) try: proc = run_fn(build_cmd) rc = getattr(proc, "returncode", 0) out = getattr(proc, "stdout", "") or "" err = getattr(proc, "stderr", "") or "" if rc != 0: return { "ok": False, "action": "build_app_bundle", "error": f"xcodebuild failed rc={rc}", "stdout": out[-2000:], "stderr": err[-2000:], "build_command": build_cmd, "app_bundle_path": str(dist_bundle), "bundle_identifier": BUNDLE_IDENTIFIER, "derived_data_path": str(derived_path), "xcodeproj_path": str(proj_path), } except Exception as exc: return { "ok": False, "action": "build_app_bundle", "error": f"xcodebuild exception: {exc}", "build_command": build_cmd, "app_bundle_path": str(dist_bundle), "bundle_identifier": BUNDLE_IDENTIFIER, "derived_data_path": str(derived_path), "xcodeproj_path": str(proj_path), } if not built_product.exists(): return { "ok": False, "action": "build_app_bundle", "error": f"built product not found after build at {built_product}", "app_bundle_path": str(dist_bundle), "bundle_identifier": BUNDLE_IDENTIFIER, "build_command": build_cmd, "derived_data_path": str(derived_path), "built_product_path": str(built_product), } try: dist_dir = app_bundle_dir(r) _ensure_dir_mode(dist_dir, 0o700) _copy_app_bundle_atomic(built_product, dist_bundle) except Exception as exc: return { "ok": False, "action": "build_app_bundle", "error": f"bundle copy failed: {exc}", "app_bundle_path": str(dist_bundle), "bundle_identifier": BUNDLE_IDENTIFIER, "build_command": build_cmd, "derived_data_path": str(derived_path), "built_product_path": str(built_product), } # Final validation (optional but informative; unsigned builds will fail validation) validation = validate_app_bundle(repo_root=r, runner=run_fn) # If signing was disabled, we don't require validation ok, but report state if disable_code_signing: return { "ok": True, "action": "build_app_bundle", "app_bundle_path": str(dist_bundle), "bundle_identifier": BUNDLE_IDENTIFIER, "executable_path": str(exe_dst), "info_plist_path": str(plist_dst), "signature_verified": validation.get("signature_verified", False), "validation": validation, "build_command": build_cmd, "derived_data_path": str(derived_path), "built_product_path": str(built_product), "unsigned_build": True, } if not validation.get("ok"): return { "ok": False, "action": "build_app_bundle", "error": f"bundle validation failed: {'; '.join(validation.get('errors', []))}", "app_bundle_path": str(dist_bundle), "bundle_identifier": BUNDLE_IDENTIFIER, "validation": validation, "build_command": build_cmd, "derived_data_path": str(derived_path), "built_product_path": str(built_product), } return { "ok": True, "action": "build_app_bundle", "app_bundle_path": str(dist_bundle), "bundle_identifier": BUNDLE_IDENTIFIER, "executable_path": str(exe_dst), "info_plist_path": str(plist_dst), "signature_verified": True, "validation": {k: v for k, v in validation.items() if k != "errors" or v}, "build_command": build_cmd, "derived_data_path": str(derived_path), "built_product_path": str(built_product), }