Files
reyna-cli/src/reyna_cli/app_bundle.py
T
Adolfo Reyna 9fd04b0ce4 feat(privacy-host): add signed native calendar contacts and reminders host
Add the owner-only AF_UNIX Reyna CLI privacy host, strict signed-app installation, and typed native routing for Calendar, Contacts, and Reminders.\n\nAdd bounded system-status paths and config-only direct local-service wrappers. Preserve MacMiniMCP pending explicit cutover approval.\n\nApple Notes is intentionally deferred: no native Notes operations, Apple Events declaration, or Automation helper are included; legacy Notes handling remains untouched.
2026-08-03 20:27:54 -04:00

711 lines
26 KiB
Python

"""Deterministic Apple-signed Reyna CLI.app bundle builder via Xcode.
Bundle layout:
<repo>/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 <project> -scheme 'Reyna CLI' -configuration Release -derivedDataPath <controlled> 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),
}