Skip to content

Design: stable TCC identity for the managed daemon — fixed-path atm-supervisor spawns the versioned atm-daemon so macOS asks once #1534

Description

@randlee

Problem

Every new atm-daemon binary path triggers a fresh macOS "access Documents folder" consent prompt for the LaunchAgent: seven prompts between 2026-09-10 and 2026-09-16 (~/.atm-builds/v1.5.20/bin/atm-daemon, /opt/homebrew/Cellar/atm/1.6.0/bin/atm-daemon, ...). tccd keys the grant by real path for our dev-signed binary (identifier_type=Path), and /opt/homebrew/bin/atm-daemon is a symlink, so every release or daemon-switch is a new identity.

Those prompts are not just an operator nuisance:

Rand's ruling (2026-09-16): moving repos out of ~/Documents, restricting the daemon to ~/.atm, and "toggle it in System Settings" are not solutions. Requested: a design that gives the managed daemon a stable TCC identity so it is granted once and never re-prompts.

Where the Documents access comes from today

  1. ~/Library/LaunchAgents/com.atm.daemon.crosshost-smoke.plist sets WorkingDirectory to /Users/randlee/Documents/github/atm-core. launchd chdirs there before exec, and atm-daemon-bootstrap reads std::env::current_dir() at startup (crates/atm-daemon-bootstrap/src/lib.rs:215). That is a Documents access on every daemon start, before it has done any work.
  2. ProgramArguments runs /usr/bin/env -u ATM_TEAM -u ATM_IDENTITY -u ATM_ENVIRONMENT /opt/homebrew/bin/atm-daemon. The symlink resolves to the versioned Cellar path; daemon-switch swaps this for ~/.atm-builds/<sel>/bin/atm-daemon. Each distinct real path is a new TCC subject.
  3. Runtime reads of roster home_dirs and worktrees that live under ~/Documents (still true for ~/Documents/github/atm-core-worktrees/* and the LaunchAgent cwd even after the primary checkouts moved to ~/github).

Proposal: atm-supervisor

A tiny, rarely-changing binary at a fixed real path (not a symlink), e.g. ~/.atm/bin/atm-supervisor, becomes the LaunchAgent's program. It spawns the currently selected atm-daemon as a child. On macOS, TCC attributes a child's file access to its responsible process, which is inherited from the parent unless the parent explicitly disclaims it. The supervisor never disclaims, so the one Documents grant given to atm-supervisor covers every daemon version it launches. The daemon binary can change freely; the subject macOS sees does not.

Supervisor contract

  • Selection: reads the active pair from the existing daemon-switch selector (~/.atm-builds/selectors/<os>, falling back to the Homebrew pair). No new config format.
  • Lifecycle: posix_spawn the daemon with the scrubbed environment (the env -u ATM_TEAM/ATM_IDENTITY/ATM_ENVIRONMENT step moves into the supervisor), inherit stdout/stderr (launchd log paths keep working), forward SIGTERM/SIGINT/SIGHUP to the child, wait, exit with the child's status. The supervisor exits when the child exits so launchd KeepAlive restarts the pair; it does not implement its own restart loop or back-off.
  • Responsibility: must NOT call responsibility_spawnattrs_setdisclaim; must not be a shell script (the interpreter would be the subject). One static Rust binary, no dependencies on the atm-core crates so it changes only when the contract changes.
  • Working directory: the LaunchAgent WorkingDirectory becomes ~/.atm (outside every TCC folder); the supervisor sets the child's cwd the same way. The daemon must not depend on cwd (today it reads current_dir() only for diagnostics; confirm).
  • Non-macOS: not shipped; daemon-switch keeps its current Linux/Windows paths.

daemon-switch changes

  • switch writes the selector and runs launchctl kickstart -k <label>; it never rewrites ProgramArguments again. The plist is written once by an install-supervisor step (idempotent) and the pre-switch probe checks the plist points at the supervisor.
  • The stale ~/.config/atm/daemon-switch.json restore defaults (1.3.2-beta.1, written by setdefault in release_resolution.py's save_default_pair) stay harmless but should be cleaned up in the same change.
  • atm doctor gains one finding: LaunchAgent program is not the supervisor, or supervisor path is a symlink.

Why not only Developer ID signing (#1126)

A notarized Developer ID build gives tccd a code-requirement identity that survives version changes, which is the right long-term answer for released binaries. It is gated on paid enrollment and does nothing for dev builds from ~/.atm-builds, which is where most of the seven prompts came from. The supervisor works today, for every build source, and still composes with signing later (sign the supervisor too and the grant survives supervisor updates).

Rejected

  • Repos out of ~/Documents / daemon confined to ~/.atm / manual System Settings toggles: rejected by Rand as non-solutions.
  • Making /opt/homebrew/bin/atm-daemon a hard link or copy: still changes per Homebrew upgrade; the supervisor is the only file that must stay put.

Acceptance

  1. Install the supervisor, grant Documents once. Then switch daemon builds three times (--release latest, a ~/.atm-builds prerelease, back): /usr/bin/log show --predicate 'process == "tccd" AND eventMessage CONTAINS "AUTHREQ_PROMPTING"' shows no prompt for any atm-daemon path; atm doctor --json healthy after each switch.
  2. launchctl kickstart -k restarts the pair; SIGTERM to the supervisor stops the daemon cleanly (socket removed, no orphan).
  3. Kernel log shows no deny(1) lines for the supervisor's pid across the switches.
  4. The daemon-switch skill tests cover: selector-only switch, plist-drift detection, supervisor-is-symlink refusal.

Scope

One sprint: supervisor crate (crates/atm-supervisor, no workspace-internal deps), daemon-switch script + plist template, doctor finding, docs (docs/user/… daemon section). No change to the daemon's own code beyond confirming cwd independence.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions