Skip to content

Opt-in privileged helper for admin operations - #346

Open
caezium wants to merge 1 commit into
mainfrom
feat/privileged-helper
Open

Opt-in privileged helper for admin operations#346
caezium wants to merge 1 commit into
mainfrom
feat/privileged-helper

Conversation

@caezium

@caezium caezium commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Replaces password-only osascript elevation with an opt-in, Developer ID-signed launch daemon, so Clean/Optimize/scan can authenticate with Touch ID. The osascript path stays exactly as it is and remains the fallback for anyone who declines the helper — declining costs the Touch ID prompt and safe cancellation, never the authentication itself.

Do not merge yet. The runtime behaviour that can only be checked on a signed build is listed at the bottom.

Why

system.privilege.admin authenticates through SecurityAgent's classic mechanism, which never offers Touch ID — the existing code comment in MoleCLI.elevatedScript already says so. The same path also can't be cancelled: terminating osascript orphans the root child it spawned, which is why OperationFlow has no safe cancel for elevated runs today.

Scope of what the helper can do

Three typed operations — scan, clean, optimize — and the daemon derives argv from the enum itself:

scan     → ["clean", "--dry-run"]
clean    → ["clean"]
optimize → ["optimize"]

These are exactly what CleanView/OptimizeView/TuneUpView pass today, so the helper changes how a command is elevated, never what runs. The request type has three fields — operation, operation ID, client build — and no field for a path, an argument, a shell string, or an executable. A caller that fully controls the XPC payload still cannot express "run this".

Security properties, and where each is enforced

Property Enforced by
Fresh auth per root operation; registering authorizes nothing Right defined with timeout: 0, shared: false
Root daemon can't authorize itself allow-root: false
The prompt is real, not cosmetic Daemon calls AuthorizationCopyRights with a non-empty rights set + interactionAllowed; client never pre-authorizes
Only Burrow can connect NSXPCListener.setConnectionCodeSigningRequirement — identifier + Apple anchor + signing team
One authorization, one operation UUID operation IDs, served at most once (HelperReplayGuard)
A stale root daemon never runs Build-skew check routes back to osascript
Root never runs an untrusted binary Engine resolved relative to the helper's own executable, signature verified before exec, PATH/env dropped

Two details worth calling out because both are one-line ways to make the whole thing decorative, and both are covered by tests:

  • The prompt is raised by the daemon, not the GUI. The obvious design — prompt with LAContext in the app, then send the request — is not an authorization at all: a caller that skipped the prompt would be indistinguishable from one that passed it. The client externalizes an empty, unauthenticated AuthorizationRef and the root side is what demands the right.
  • Caller identity comes from the system, not a PID lookup. Reading connection.processIdentifier and verifying that process is the classic vulnerable pattern (PIDs get recycled and raced). Doing it properly needs the audit token, and reaching an NSXPCConnection's audit token means private API — not a dependency worth taking in a root daemon. The macOS 13 requirement API is supported and evaluated in the kernel against the real peer.

Routing

PrivilegeRoute.decide is pure. The helper is used only when the argv maps onto a typed operation and the daemon is registered and enabled and the build matches. Every other combination returns .osascript. PrivilegeRouteTests walks the full cross-product so no future edit can produce a .helper route from a state that isn't fully green.

Release gate

sign-macos-app.sh now fails closed if the helper is missing, not Mach-O, unsigned, not hardened, on a different team, or misdeclared to launchd — including a BundleProgram pointing at anything other than the executable the pipeline just verified, which is the one that would put an unvetted binary behind root. The gate is mandatory, so "no helper" is an error rather than a skip; the existing signer test fixtures were updated to stage one rather than weakening the check.

Docs

SECURITY.md said "Burrow installs no privileged/background helper and no XPC root service." That is now scoped to the default configuration, with the opt-in helper's guarantees spelled out. README's equivalent claim updated too.

Verification done

  • 835 Swift tests, 0 failures, 1 pre-existing skip — including 4 new suites (HelperContractTests, HelperAuthorizationTests, HelperCodeRequirementTests, PrivilegeRouteTests).
  • 17 signer script tests including 7 new privileged-helper gate cases.
  • Local Debug build: helper staged at Contents/MacOS/BurrowHelper, plist at Contents/Library/LaunchDaemons/, CFBundleVersion baked into the binary, strict signature verification passes.

Still needs an on-device check before merge

None of this can be exercised without a Developer ID build and a real admin approval:

  1. Daemon-side interactive authorization. AuthorizationCopyRights with interactionAllowed, called from a root daemon against a client-supplied external form, is the documented pattern — but that the prompt appears correctly in the user's session, and that timeout: 0 doesn't make it re-prompt mid-operation, needs to be seen.
  2. Whether SecurityAgent actually offers Touch ID for this right. Touch ID presentation is SecurityAgent's decision, not something the app requests. The guaranteed floor is the login password; if Touch ID doesn't appear for a custom class: user / group: admin right, the security properties are unchanged but the headline benefit isn't there, and the rule needs revisiting.
  3. Apple Watch. LAPolicy.deviceOwnerAuthentication accepts a paired Watch; SecurityAgent does not. That is a deviation from the decision as originally framed — the Touch-ID-then-password behaviour is preserved, the Watch is not.
  4. SMAppService.register() approval flow and removal via Login Items & Extensions.

Burrow's elevated operations run through `osascript … with administrator
privileges`, which is password-only by construction: the
`system.privilege.admin` right authenticates through SecurityAgent's classic
mechanism, which never offers Touch ID. It also cannot be cancelled safely —
killing osascript orphans the root child it spawned.

This adds an opt-in `SMAppService` launch daemon as an alternative route, and
leaves the osascript path in place as the fallback for anyone who declines it.

The helper accepts three typed operations — scan, clean, optimize — and derives
argv from the enum itself. There is no field for a path, an argument, a shell
string, or an executable, so a caller that fully controls the XPC payload still
cannot express "run this".

Security properties, each enforced rather than documented:

- Fresh authentication per root operation. The right is defined with
  `timeout: 0`, `shared: false`, and `allow-root: false`, so no credential
  survives a call, none is shared, and the root daemon cannot satisfy the
  right by virtue of being root. Registering the helper authorizes nothing.
- The prompt is raised by the DAEMON. The client externalizes an empty,
  unauthenticated `AuthorizationRef`; the daemon rebuilds it and calls
  `AuthorizationCopyRights` with a non-empty rights set and interaction
  allowed. A GUI-side prompt would be cosmetic — the privileged side has to be
  what demands the right.
- Callers are pinned by `NSXPCListener.setConnectionCodeSigningRequirement`
  (bundle identifier + Apple anchor + signing team), evaluated by the system
  against the real peer. The requirement is built at runtime from the helper's
  own signing information, so no team ID is hardcoded.
- One authorization, one operation: operation IDs must be UUIDs and are served
  at most once, so a captured payload cannot be replayed.
- Version skew is refused. A registered daemon outlives the app that installed
  it, so a build mismatch routes back to osascript instead of running as root
  with a stale idea of what `clean` does.
- The engine is resolved relative to the helper's own executable and its
  signature is verified before it runs as root — never PATH, never an
  environment variable.

Routing is a pure function: the helper is used only when the argv maps onto a
typed operation, the daemon is registered and enabled, and its build matches.
Anything else keeps the existing path unchanged.

sign-macos-app.sh now fails closed if the helper is missing, unsigned, not
hardened, or misdeclared to launchd — including a BundleProgram that points at
anything other than the executable the pipeline just verified.

SECURITY.md previously stated that Burrow installs no privileged helper and no
XPC root service; that claim is now scoped to the default configuration and the
opt-in helper's guarantees are spelled out.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant