Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

bigas-ch/macOSUpdater

Open more actions menu

Repository files navigation

macOSUpdater

CI License: MIT Platform: macOS Shell: zsh + bash 3.2

A security-first macOS update manager: a zsh fzf TUI on top of a hardened root LaunchDaemon backend. It runs privileged softwareupdate operations without keeping a sudo token in RAM and without prompting for a password on every run — while keeping the privileged surface minimal, UID-validated and action-whitelisted.

User-space updates (Homebrew, Mac App Store, npm, pip, gh) run as your normal user; only Apple's softwareupdate is delegated to the root daemon.


Why

Most "update everything" scripts either nag for sudo repeatedly or cache a privileged token. macOSUpdater instead ships a tiny root daemon that does exactly one thing — run softwareupdate -i -a — and is triggered by a user-owned file. The daemon validates who triggered it, accepts only a fixed set of actions, and never evaluates the trigger content as a shell command. See SECURITY.md for the full threat model.

Requirements

  • macOS (developed and tested against the macOS 26 softwareupdate line).
  • System zsh (daemon) and bash 3.2 (CLI) — both ship with macOS.
  • fzf — only for the interactive menu (brew install fzf). All subcommands work without it.
  • Per-subcommand, optional: Homebrew (brew, casks), mas (apps), uv/pipx (pip), npm/pnpm (npm), GitHub CLI (gh).
  • For development/tests: bats and shellcheck.

Install

git clone https://github.com/bigas-ch/macOSUpdater.git
cd macOSUpdater

# One-time, idempotent. Deploys the root LaunchDaemon (asks for sudo once) and
# creates macOSUpdater.app in /Applications (with ~/Applications fallback).
./setup_macOSUpdater_v1.0.0.sh

The setup copies the daemon, plist and a CLI client into /Library/Application Support/ch.bigas.macOSUpdater/ (all root:wheel), loads the LaunchDaemon ch.bigas.macOSUpdater, and creates the log at /var/log/macOSUpdater.log. Re-running is a safe no-op.

Usage

macOS Update Manager v1.0.0
Usage: macOSUpdater_v1.0.0.sh [argument] [--backup]

  (no argument)   Interactive fzf menu
  all             All available updates
  macos           macOS software + security updates (via the root daemon)
  brew            Homebrew: update → upgrade → autoremove → cleanup
  casks           Homebrew Casks (GUI apps)
  apps            Mac App Store (mas update)
  pip             Python tools (uv / pipx)
  npm             Global Node packages
  gh              GitHub CLI extensions
  --backup        Time Machine backup before 'all' (pre-hook)
./macOSUpdater_v1.0.0.sh                # interactive fzf menu
./macOSUpdater_v1.0.0.sh all            # everything
./macOSUpdater_v1.0.0.sh all --backup   # + Time Machine pre-hook
./macOSUpdater_v1.0.0.sh macos          # macOS updates (delegated to the daemon)
./macOSUpdater_v1.0.0.sh brew           # 4-step Homebrew pipeline

Or double-click macOSUpdater.app in /Applications to open the menu in Terminal.

Quiet by default: external tools run silently and only print on a real error (non-zero exit). Successful steps show ✓ Done. This suppresses npm warning walls (EBADENGINE / deprecated / allow-scripts) without pattern filtering.

How it works

                  ┌─────────────────┐
                  │ You trigger via │
                  │ the zsh CLI     │
                  └────────┬────────┘
                           │ writes
                           ▼
            $HOME/.macOSUpdater_trigger        (action payload)
                           │ WatchPaths
                           ▼
        LaunchDaemon  ch.bigas.macOSUpdater    (root)
        /Library/LaunchDaemons/...
                           │
                  ┌────────┼─────────┐
                  ▼        ▼         ▼
               UID/     Action-   softwareupdate
               owner    whitelist   -i -a
               check
                           │
                           ▼
            $HOME/.macOSUpdater_done           (result marker)
                           │
                           ▼
                    CLI returns + run summary + notification

Security layers (full detail in SECURITY.md):

  • TOCTOU-closed trigger read — the daemon opens the trigger with O_NOFOLLOW, fstats the descriptor, and reads from the same fd; check and use hit the same inode (no symlink/inode-swap window).
  • Owner-verified constants source_constants.sh is sourced only after an O_NOFOLLOW + fstat owner check (== daemon EUID); the daemon defends itself independently of where it is deployed.
  • Action whitelist — only macos_sw / macos_sw_restart / all; anything else exits 1. No eval, no command substitution on the trigger content.
  • PATH pinningPATH=/usr/bin:/bin:/usr/sbin:/sbin, external commands by absolute path (closes injection via user-writable Homebrew dirs).
  • Fixed install owner — baked at install time, not derived from $USER.
  • Uninstaller allowlist — every destructive rm/rmdir validates its target against empty input, path traversal and protected system paths.
  • NDJSON logging — forensic-grade structured log at /var/log/macOSUpdater.log.

Watch the daemon live:

sudo tail -f /var/log/macOSUpdater.log | jq .

Architecture notes

The CLI front-end and the root daemon are versioned independently. The daemon runs as root, so it is touched only for functional/security changes (action whitelist, UID validation, NDJSON schema) to keep the attack surface minimal — the CLI can iterate freely without re-deploying the daemon. The daemon announces its version on every wake:

{"ts":"","level":"info","event":"daemon_started","msg":"v1.0.0 daemon awakened by trigger"}

Repository layout

macOSUpdater/
├── macOSUpdater_v1.0.0.sh          ← main CLI (bash 3.2)
├── setup_macOSUpdater_v1.0.0.sh    ← installer (LaunchDaemon deploy + .app launcher)
├── uninstall_macOSUpdater.sh       ← uninstaller (allowlist-guarded)
├── macOSUpdater_daemon.sh          ← root LaunchDaemon source (zsh)
├── ch.bigas.macOSUpdater.plist     ← LaunchDaemon plist
├── _constants.sh                   ← single source of truth (paths, labels)
├── assets/                         ← app icon + reproducible generator
├── tests/                          ← bats suite (security, daemon, drift guards, …)
├── .githooks/pre-commit            ← local quality gate (zsh -n + ShellCheck + bats)
├── .github/workflows/ci.yml        ← macOS CI (lint + bats)
├── CHANGELOG.md                    ← Keep a Changelog
├── SECURITY.md                     ← threat model + disclosure
└── LICENSE                         ← MIT

Development & tests

bats tests/                 # full suite — security, daemon, drift guards, CLI execution
git config core.hooksPath .githooks   # opt into the local pre-commit gate

CI runs a lint job (zsh -n for the zsh track, shellcheck -S style for the bash CLI) plus the full bats suite on macOS. Tests are designed to run without touching the live system (MACUP_SKIP_DAEMON_CHECK=1 where applicable).

Uninstall

./uninstall_macOSUpdater.sh -n      # dry-run: shows what would be removed
./uninstall_macOSUpdater.sh         # interactive, with log-backup option

Removes the LaunchDaemon, the support directory, the plist, the trigger/done markers and the .app launcher.

Security

Found a root-relevant issue? Please report it privately to micha.barth@bigas.ch — see SECURITY.md. Do not open a public issue for vulnerabilities.

License

MIT © Micha Barth <micha.barth@bigas.ch>

Reverse-domain ch.bigas is used for the plist bundle identifier.

About

Security-first macOS update manager: zsh fzf-TUI plus root LaunchDaemon backend (passwordless, UID-validated, action-whitelisted privileged updates).

Topics

Resources

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages

Morty Proxy This is a proxified and sanitized view of the page, visit original site.