Skip to content

feat(ui): guide macOS users to grant Full Disk Access when files are denied - #205

Closed
pmaxhogan wants to merge 1 commit into
feat/macos-apfs-wiringfrom
feat/macos-fda-onboarding
Closed

pmaxhogan wants to merge 1 commit into
feat/macos-apfs-wiringfrom
feat/macos-fda-onboarding

Conversation

@pmaxhogan

@pmaxhogan pmaxhogan commented Jul 29, 2026

Copy link
Copy Markdown
Owner

The Full Disk Access onboarding surface for macOS TCC denials.

Stacked on #201 (base is feat/macos-apfs-wiring), which is itself stacked on #196. Rebase onto main once those land.

It also depends on #195 at runtime, but shares no lines with it. #195 is what makes the executor emit local.permission_denied; this PR is the UI that reacts to that code. The banner keys off the code as a plain string in entry.eventType, so nothing here needs #195 to compile or to pass tests - but the banner stays inert until #195 merges. I deliberately did NOT add the errors.local.permission_denied i18n entry, because #195 adds exactly that key and duplicating it would guarantee a conflict. All new strings live under a new fdaBanner namespace.

Why a banner at all

A TCC denial is invisible from the user's side: the backup "succeeds", certain files just never appear in Drive. The activity row alone does not tell anyone what to do about it. So the UI has to name the problem and point at the fix.

Detection

A root-mounted FdaBanner subscribes to activity:new (the ToastHost pattern, so a denial during a background cycle is not missed because the user was on another tab) and latches on the first row whose eventType is local.permission_denied.

No per-cycle bookkeeping: a denial is PERMANENT until the user acts, unlike a lock, so "has this ever happened" is the right question. Gating on backup_done would have been wrong twice over - failed ops suppress that row, and it is emitted per source rather than per cycle.

Dedupe

Since a denial is permanent, the same file emits one warn row EVERY cycle, forever. The banner counts DISTINCT files rather than rows, so a file denied across fifty cycles is reported once. Kept entirely in the display layer - the executor's emission and the activity store are untouched, so the raw rows stay available for diagnostics.

The deep link, and the trap under it

Button opens the FDA pane directly with:

x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles

Verified on this macOS 26.6 (25G72, arm64) machine, three ways:

  • Opening it lands on a window titled exactly Full Disk Access.
  • Control: the same URL with a bogus anchor (?Privacy_BogusAnchorXYZ) lands on Privacy & Security instead - so the anchor is genuinely load-bearing and the result is not a false positive.
  • Real-world case: with System Settings already open on a different pane (Microphone), the deep link still navigates to Full Disk Access rather than no-opping.

opener:default would have blocked this. Its scope only permits mailto:, tel:, http://, https://, so openUrl with a custom scheme throws at runtime while every mocked test still passes. src-tauri/capabilities/default.json now carries an explicit scoped entry:

{ "identifier": "opener:allow-open-url", "allow": [{ "url": "x-apple.systempreferences:*" }] }

Verified end to end: the entry is present in the generated src-tauri/gen/schemas/capabilities.json, and glob::Pattern::new("x-apple.systempreferences:*") (the same glob crate the opener plugin matches with) returns true for the exact target URL and false for file:///etc/passwd, https://evil.example, and x-apple.systemprefsX:y - so the scope admits the pane and nothing else.

The unsigned-binary caveat (real, recurring)

macOS binds a TCC grant to the binary's code signature (cdhash), not its path. Driven's V1 builds are unsigned, so every update is a different program as far as TCC is concerned, and a working grant can silently stop applying - the app still shows in the Full Disk Access list with its switch on while being denied. This is stated in one line in the banner copy and explained with the remove-and-re-add fix in the README.

Docs

  • README: a new "macOS: Full Disk Access (privacy protection)" section (what TCC blocks, the 3-step grant, the unsigned-binary invalidation and its fix) and a "What locked-file backup covers on macOS (and what it does not)" section with a table splitting local.file_locked (fix: APFS snapshot toggle) from local.permission_denied (fix: FDA, nothing else).
  • DESIGN s5.3.3: the onboarding design - trigger, dedupe rationale, the deep link and its capability-scope requirement, why dismissal is per-session, and the unsigned-binary caveat.
  • I did NOT touch SPEC's error-code list; fix(core): classify macOS locked and permission-denied opens into the skip-and-report path #195 owns that entry.

Accuracy point held throughout: APFS snapshots do NOT bypass TCC. A snapshot mount preserves the original's ownership and is itself TCC-gated, and the -o noowners bypass was CVE-2020-9771 - patched, and now an EDR-flagged signature Driven deliberately does not emit. Nothing in these docs implies snapshots substitute for FDA; the README table makes the split explicit.

Judgment calls

  • Dismissal is per-session (in-memory), not persisted. localStorage/sessionStorage appear zero times in ui/src, and the only dismissible-banner precedent (stores/updater.ts) is an in-memory Pinia ref, so this matches it. It is also the better semantic: the condition is unresolved until FDA is granted, so permanently silencing it would hide an ongoing data-coverage gap. Once granted, the denials stop and the banner stops on its own. A persisted flag would have meant a new settings-schema field, and this stack already adds one.
  • No platform check. local.permission_denied can only be emitted on macOS, so the banner is inherently mac-only - the same way the VSS banner derives purely from backend state rather than sniffing the userAgent.
  • Live events only, no backfill query. The banner is root-mounted for the app's whole lifetime, so it catches every denial while running; a denial from a previous run resurfaces on the next cycle. Adding a startup query_activity backfill would show it a little sooner and is an easy follow-up, but it is not needed for correctness.

Gates

  • pnpm --dir ui lint, test:unit, build, format:check - all green
  • SQLX_OFFLINE=true cargo test --workspace, cargo clippy --workspace --all-targets -- -D warnings, cargo fmt --all -- --check - all green
  • New FdaBanner.vue has a vitest mount test (coverage gate), covering hidden-by-default, show-on-denial, ignore-other-codes, dedupe, multi-file count, the exact deep-link URL string, dismissal stickiness, and the unsigned-binary note.

Not verified

The in-app path is not exercised end to end. cargo tauri dev never passes --config, so there is no dev bundle with the sidecar, and the UI tests mock @tauri-apps/plugin-opener - they prove the button calls openUrl with the right string, not that the call succeeds inside a running Driven. What IS verified is each link in that chain independently: the URL against real System Settings with a control, the capability entry in the generated ACL, and the glob match against the shipped pattern.


Note on CI: ci.yml, coverage.yml and friends are declared on: pull_request: branches: [main], so a PR whose base is a feature branch gets NO checks beyond the PR-title gate. That is why every gate in this description was run locally on this machine and reported verbatim rather than pointed at a green CI run. Retargeting this PR to main after the parent PRs merge will trigger the real matrix.


Update: #195 merged to main (08d2864) while this was being written. That is the PR that makes the executor emit local.permission_denied, so once this stack is rebased onto main the banner is live rather than inert. Nothing here needs changing - this PR still shares no lines with #195, and deliberately does not define the errors.local.permission_denied i18n key that #195 already added.

Cross-checked the predicate against merged main, since a wrong literal would make this banner silent dead code that no gate in this repo would catch:

So the activity row's event_type is exactly the string this banner matches on.

Merge-conflict warning for whoever resolves this one: ui/src/__tests__/app-shell.test.ts contains a single-line change, expect(listenMock).toHaveBeenCalledTimes(8) -> (9), because mounting FdaBanner at the app root adds one listen registration. That number is an arithmetic invariant, not a preference. If another PR also adds a root-level subscription and touches the same line, the resolution must be the SUM of both additions, not either side's value. Picking one side would silently lose a subscription's worth of coverage while still passing.


Rebased onto the re-cut #201 (which is now based on main)

Re-cut and re-applied rather than rebased through #196's squash-merge. Retarget this to main once #201 lands.

#195 is on main now, so the local.permission_denied rows this banner keys off are really emitted - the banner is live, not inert. Still shares no lines with #195 and still does not define the errors.local.permission_denied key that #195 owns.

The app-shell.test.ts listener count is still correct arithmetic after the rebase: main is at 8, this PR is at 9 (+1 for FdaBanner). Main's exclusion-preview listener fix (#206) did not add a root-level App.vue subscription, so no adjustment was needed. The merge-conflict warning above still applies.

Added: a user-facing note that this feature is inert on a stock install

While verifying #201's sidecar work I found that #196's broker refuses to serve when the helper's directory is writable by the client uid - and every DMG drag-install produces exactly that, including into /Applications (measured across 7 real apps on macOS 26.6; only pkg/App-Store installs are root:wheel). Confirmed by running the real broker binary from a user-owned directory and getting its refusal.

Since the README is where a user would look, the "What locked-file backup covers on macOS" section now carries a blockquote saying plainly that the APFS snapshot setting does not work from a drag-installed .app, why (the helper cannot authenticate a caller who could plant a binary beside it), and that fixing it needs a .pkg-style install that leaves the bundle root-owned. It also says this is a deliberate safety property, not a bug - so nobody "fixes" it by weakening the check.

This does not affect the FDA banner itself, which is about TCC denials and works regardless of install layout.

Gates re-run after the rebase

UI: lint 0 errors, 549 tests / 44 files, build clean, format:check clean. Rust: cargo test --workspace 41 suites / exit 0, clippy -D warnings clean, cargo fmt --all --check clean.


Correction: the README note about drag-installs was wrong and has been rewritten

The blockquote this PR previously added said locked-file backup is inert on a drag-installed .app and framed the helper's refusal as a deliberate safety property. #211 downgraded that check from fatal to advisory, so that is no longer true. I have replaced the blockquote rather than deleted it, because the situation is still worth one honest paragraph for users - just not the one I wrote.

The replacement says, in user-facing language:

  • One defence-in-depth check is weaker on a normal install. The helper confirms that whatever talks to it sits next to it in the same folder, which only proves much if you could not write to that folder yourself - and dragging an app out of a .dmg makes you the owner of its contents (true even into /Applications; only .pkg/App Store installs land root-owned). So the check is advisory there and the helper records a DEGRADED line in its own log.
  • Locked-file backup still works. The checks carrying the real weight are unaffected: the helper only talks to your own user account, only mounts volumes Driven listed at launch, and only makes read-only mounts preserving original file ownership.
  • Someone defeating the folder check would already have to be running as you, and would gain a read-only copy of files they could already read.
  • A .pkg install would restore full strength - an improvement, not a prerequisite.

No scare framing, and no claim that it does not matter at all.

None of this affects the FDA banner itself, which is about TCC denials and behaves identically regardless of install layout.

Gates after the rebase and rewrite

UI: lint 0 errors, 549 tests / 44 files, build clean, format:check clean. Rust: cargo test --workspace 41 suites / exit 0, clippy -D warnings clean, cargo fmt --all --check clean. The app-shell.test.ts listener count is still main+1 (8 -> 9); the merge-conflict note above still applies.


Rebased, and reconciled with #209's docs refresh

#209 landed a README section, "macOS Full Disk Access (separate from Gatekeeper)", that overlaps what this PR was adding. Rather than carry a near-duplicate section, I dropped my version and edited #209's in place. The net diff is now much smaller and the README has one FDA section instead of two.

What changed in #209's section:

  • "There is currently no in-app guided flow for granting Full Disk Access - that is planned for a future release" was the sentence this PR falsifies. Replaced with the real flow: the banner appears on the first refused read, has a button that opens the pane directly, and is dismissible. Plus the three grant steps, including "quit and reopen Driven" (a grant only applies to a newly launched process, which is the step people miss).
  • Added the unsigned-binary caveat, which docs: refresh the docs for the 2.5.0 feature set #209's section does not cover: macOS binds the grant to the binary's cdhash, Driven is unsigned, so an update can silently invalidate a working grant while the app still appears in the list with its switch on. Fix is remove-and-re-add.
  • Kept docs: refresh the docs for the 2.5.0 feature set #209's "a locked-file snapshot is not a substitute for Full Disk Access" paragraph and did not duplicate it. It is well written and already carries the CVE-2020-9771 history.

Flipped the roadmap bullet. #209 left a DRAFT - do not uncomment until the corresponding PR merges block containing "Guided Full Disk Access onboarding for macOS ... (#205, unmerged.)" - which is this PR. It is now a live feature bullet and the draft entry is gone, so the README is accurate the moment this merges. The remaining draft bullets (S3, local destination, scrub, restore drill, rclone importer) are untouched, and the comment wrapper stays for them.

Gates

UI: lint 0 errors, 559 tests / 45 files, build clean, format:check clean. Rust: cargo test --workspace --exclude driven-backend 43 suites / exit 0, clippy -D warnings clean, cargo fmt --all --check clean.

The exclusion is not this branch's doing - see the note on #201: #200's new driven-backend crate has two keychain tests that block on an invisible macOS SecurityAgent prompt on this machine. They pass in CI, and neither of these PRs touches that crate.


Rebased onto the re-cut #201, which now sits on main with #211, #207 and #209

Still needs retargeting to main once #201 lands.

Gates re-run, now WITHOUT the --exclude driven-backend workaround

#207 fixed the keyring mock, so the full suite runs again:

  • SQLX_OFFLINE=true cargo test --workspace - 48 suites, exit 0, no exclusions
  • pgrep SecurityAgent empty throughout - no keychain prompt raised
  • clippy -D warnings clean, cargo fmt --all --check clean
  • UI: lint 0 errors, 566 tests / 45 files, build clean, format:check clean

The app-shell.test.ts listener count remains main + 1 for FdaBanner; the merge-conflict note earlier in this description still applies.

@github-project-automation github-project-automation Bot moved this to Todo in Driven Jul 29, 2026
@pmaxhogan
pmaxhogan force-pushed the feat/macos-apfs-wiring branch from f92d46c to 37ac481 Compare July 29, 2026 18:19
@pmaxhogan
pmaxhogan force-pushed the feat/macos-fda-onboarding branch from 51c69b4 to ca05ccb Compare July 29, 2026 18:20
@pmaxhogan
pmaxhogan force-pushed the feat/macos-apfs-wiring branch from 37ac481 to d19aaa9 Compare July 29, 2026 18:30
@pmaxhogan
pmaxhogan force-pushed the feat/macos-fda-onboarding branch from ca05ccb to c7177a5 Compare July 29, 2026 18:32
pmaxhogan added a commit that referenced this pull request Jul 29, 2026
## Summary

Refreshes user-facing and design docs for the 2.5.0 feature set,
verified
against merged code (not PR descriptions) rather than restated from
them.

- **README + comparison table**: sharpens the macOS section - Full Disk
Access is now documented as a separate permission from Gatekeeper, with
an
explicit statement that a locked-file snapshot is not a substitute for
it
  and that Driven does not use the patched `-o noowners` (CVE-2020-9771)
bypass. Adds footnote 41 to the "Locked / open-file backup" comparison
row
  scoping the claim precisely to what's actually true today.
- **design/DESIGN.md**: fixes two now-false "the maintainer has no Apple
hardware" statements - Apple hardware is available and macOS-specific
work
this cycle (locked/permission-denied classification, the APFS broker,
the
template tray icon) was verified on it. Code signing (Apple Developer
ID)
  is called out as the actual remaining blocker, separate from hardware
  access.
- **design/ROADMAP.md + STRESS_HARNESS.md**: fixes several stale
"skipped
until M4 lands" / `if: false`-gated CI descriptions - the real-Drive
gate
  flipped long ago (`chaos-real-drive` now runs on `v*` tag pushes and
degrades to a clean skip without secrets, per
`.github/workflows/chaos.yml`
s not on a nightly schedule as previously documented, and `chaos-soak`
is
a local-only task, not a CI cron). Updates the "Beyond V1" list to mark
the macOS VSS-equivalent and multi-backend items with their real current
status. Adds brief post-GA status notes to both files so they read as
the
  historical build logs they now are.
- **design/IMPLEMENTATION.md**: adds a status note pointing to
`design/E2E_REAL.md` for current e2e credential state, since this file's
  M4 OAuth preflight section is historical.
- **Landing page** (`site-landing/index.html`): narrows the code-signing
note to say what's actually verified (macOS-specific work tested on real
  Apple Silicon hardware), not a broader "builds are tested" claim.
- **Draft-only sections**: README and the landing page each get an HTML
  comment block listing the still-unmerged features (S3 backend, local/
  removable-folder backend, `driven-remote` crate, FDA onboarding UI,
scheduled integrity scrub, restore drill, rclone config importer) so
they
can be uncommented individually as each PR lands, rather than guessed
at.
- CHANGELOG.md and version numbers were not touched (release-please owns
them).

## Key correction - please read

The task brief asked me to document that "locked-file backup via APFS
snapshots works" on macOS. **It does not, yet.** `driven-apfs` (the
broker
crate from #196) is referenced only by the workspace `Cargo.toml` and
its own
files - nothing in `driven-core` or `driven-vss` calls into it.
`crates/driven-vss/src/provider.rs:276-279`
(`#[cfg(not(windows))] fn map_for_volume`) still unconditionally returns
`SnapshotOutcome::Unavailable`, so every locked file on macOS still
routes to
a plain skip, exactly as before this cycle. This matches the brief's own
"still in flight" list, which separately named "#201/#205 (macOS
locked-file
wiring + Full Disk Access onboarding UI)" as unmerged - the two halves
of the
brief were in tension, and I resolved it toward what the code (and the
in-flight list) actually says. Docs here describe what shipped
precisely:
accurate locked-vs-denied *classification* (#195, real and working), not
locked-file *backup* (still unwired).

## Test plan

- [x] `git diff --check` - clean, no whitespace errors
- [x] Swept the staged diff for non-ASCII on added lines; only
pre-existing
per-file conventions remain (README's superscript footnote numbers,
design docs' `§` section markers) - no em-dashes or en-dashes anywhere
      in the diff
- [x] `git ls-files --eol` on every touched file - all LF
- [x] Docs-only change; no cargo/pnpm gates apply (nothing under
      `crates/`, `src-tauri/`, or `ui/` touched)
- [ ] Human review of the "Key correction" section above and the
draft-only
      blocks before merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Qu8GxMwkuxF7JBzwRjtcw7

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@pmaxhogan
pmaxhogan force-pushed the feat/macos-fda-onboarding branch from c7177a5 to 4518a88 Compare July 29, 2026 18:49
@pmaxhogan
pmaxhogan force-pushed the feat/macos-apfs-wiring branch 2 times, most recently from f431658 to beb9dc4 Compare July 29, 2026 19:11
@pmaxhogan
pmaxhogan force-pushed the feat/macos-fda-onboarding branch from 4518a88 to fe3ea32 Compare July 29, 2026 19:11
@pmaxhogan
pmaxhogan force-pushed the feat/macos-apfs-wiring branch from beb9dc4 to 2219feb Compare July 29, 2026 19:16
@pmaxhogan
pmaxhogan force-pushed the feat/macos-fda-onboarding branch from fe3ea32 to 106547f Compare July 29, 2026 19:16
@pmaxhogan
pmaxhogan force-pushed the feat/macos-apfs-wiring branch from 2219feb to ce89cd4 Compare July 29, 2026 19:38
…denied

A TCC denial is invisible from the user's side: the backup "succeeds",
some files just never reach Drive. Add a root-mounted dismissible banner
that latches on the first local.permission_denied activity row, counts
DISTINCT files (a denial is permanent, so the same file is re-reported
every cycle, unbounded), and offers a one-click deep link to the Full
Disk Access pane.

The deep link needs an explicit opener capability scope: opener:default
only permits mailto/tel/http/https, so a custom scheme would throw at
runtime while every mocked test still passed.

Document in README and DESIGN s5.3.3 that APFS snapshots do NOT bypass
TCC, and that an unsigned binary's TCC grant can silently invalidate on
update because macOS binds the grant to the code signature.
@pmaxhogan
pmaxhogan force-pushed the feat/macos-fda-onboarding branch from 106547f to 650ebab Compare July 29, 2026 19:39
pmaxhogan added a commit that referenced this pull request Jul 29, 2026
…ker (#201)

Wires the `ApfsBrokeredProvider` from #196 into the app the way
`BrokeredVssProvider` is wired on Windows, and ships the
`driven-apfs-helper` broker as a Tauri sidecar on both darwin targets.

**Stacked on #196** (base is `feat/macos-apfs-snapshot`, not `main`). It
does NOT depend on #195. Rebase onto `main` once #196 squash-merges.

## Provider selection

`assembly::build_vss` now has three arms instead of two - Windows VSS,
macOS APFS, Linux `None`:

- The macOS arm builds an `ApfsBrokeredProvider` over a single app-owned
`ApfsHelperManager` (new, `src-tauri/src/apfs_helper.rs`), and the SAME
`Arc` is coerced to `Arc<dyn HelperLauncher>` for every account's
provider. One broker, one socket, one administrator prompt per session -
the same sharing contract the Windows manager documents.
- `build_apfs_helper_manager` mirrors `build_vss_helper_manager`: built
at boot REGARDLESS of the setting, so flipping the toggle on takes
effect with no app restart. A disabled manager short-circuits to
`HelperLaunchStatus::Disabled` before ever consulting the launcher, so
nothing prompts at silent startup.
- `AppState` gains `apfs_helper` storage plus the quit-sweep
`shutdown_apfs_helper()`, so no root process and no mounted snapshot
outlives the session.

`ApfsHelperManager` is deliberately thinner than `VssHelperManager`: the
launch state machine (at-most-once osascript consent, background thread,
memoised decline) already lives in `driven_apfs::OsascriptLauncher`, so
the manager only adds the setting gate and session identity. A re-enable
installs a *fresh* launcher, which is exactly how the crate documents
clearing a memoised decline.

## Settings

New `macos` group (SPEC s22), `macos.apfs_snapshot`, default `false`:

- Additive and `#[serde(default)]` on both the DTO and the
`storage::Macos` on-disk struct, so a settings DB written before this
feature loads unchanged. No migration needed (the `windows` group is
never seeded either - `load_group` returns `None` and the default fills
in). There is a test for exactly this:
`macos_group_absent_from_an_older_db_reads_as_off`.
- Gated to macOS the same way `windows` is gated to Windows:
`get_settings` returns the group as `None` off macOS, and that
nullability IS the UI's platform check. No userAgent sniff.
- **Wired through `reconfigure`**: the toggle drives the provider's
`VssMode` (on -> `Auto`, off -> `Never`), so a change is applied between
cycles by the existing `reconfigure` -> `VssProvider::set_mode` path.
The toggle also (dis)arms the shared manager after persistence and fires
the attended prompt eagerly, mirroring `windows.vss_helper`. The two
mechanisms are belt-and-braces; either alone would disable the fallback.

`get_apfs_helper_status` mirrors `get_vss_helper_status` so the toggle
can show pending -> ready/declined. Without it a declined administrator
prompt would fail silently, which is why it is in this PR rather than
deferred.

## Sidecar build + ship

Mirrors the Windows helper exactly:

- New `src-tauri/tauri.apfs-helper.conf.json` carrying `externalBin:
["binaries/driven-apfs-helper"]`, merged only on darwin via the matrix
`helperConfig` in both `release.yml` and `dev-channel.yml`. Same
split-config trick as `tauri.helper.conf.json`: each OS's bundle
references only its own sidecar, and the cargo-only CI gates (which
never pass `--config`) stay free of both.
- A `Build APFS helper sidecar (macOS)` step in both workflows prebuilds
`-p driven-apfs --bin driven-apfs-helper` and stages it
target-triple-suffixed into `src-tauri/binaries/` (`tauri build` does
not build the helper bin itself). Source path is the workspace-root
`target/`, verified empirically.
- Verified that Tauri's `copy_binaries_to_bundle` places an externalBin
in `Contents/MacOS/` - the same directory as the app binary - so the
Windows `current_exe().parent()` resolution works unchanged on macOS.
- `.gitignore`'s `/src-tauri/binaries` comment updated to cover both
helpers.

## Gates

- `SQLX_OFFLINE=true cargo test --workspace` - green
- `cargo clippy --workspace --all-targets -- -D warnings` - green
- `cargo fmt --all -- --check` - green
- `pnpm --dir ui lint && test:unit && build` - green (531 UI tests).
`format:check` also green.
- `actionlint` clean on both edited workflows (CONTRIBUTING
requirement).
- New coverage: 11 Rust tests (6 manager gate tests + 5 settings tests)
and 5 UI component tests. `Settings.vue` coverage went 94.6 -> 95.23
%stmts, 88.88 -> 90.05 %branch, so the coverage gate has no new
uncovered branches.

## Things the reviewer should know

- **This PR contains a 2-hunk `cargo fmt` fix to
`crates/driven-apfs/tests/broker_integration.rs`, which is #196's file,
not mine.** `cargo fmt --all -- --check` fails on #196 as it currently
stands. I did not otherwise touch that crate. Drop those hunks if #196
fixes it first.
- **No local dev path for the sidecar exists, on either platform.**
`cargo tauri dev` never passes `--config`, so it never resolves
`externalBin`; the Windows helper has the same gap. That means the
enable-toggle's end-to-end launch (osascript prompt -> broker -> mount)
is NOT exercisable from a dev build, and I have not run it. What I did
verify on macOS 26.6 hardware: `tmutil localsnapshot` succeeds with no
Time Machine destination configured (so the feature does not require TM
to be set up), and the helper binary builds and links on darwin.
- **macOS bundles are not signed or notarized today** (no `APPLE_*`
secrets in `release.yml`). Adding an externalBin to an unsigned `.app`
builds fine, but a nested sidecar Mach-O will need its own signature the
moment notarization is turned on.
- `lockedFileBackupDegraded` is computed and exposed but not yet
rendered as a banner; the Windows side has one. Deliberate - the
FDA/denial surface is #PR2's job and a second degraded banner there
would compete with it.



---

**Follow-up commit `f92d46c` (post-review self-catch):** reading the
helper status must never trigger the administrator prompt.

`OsascriptLauncher::launch_status()` is not a pure read - its
`NotAttempted -> InFlight` transition is exactly what spawns the consent
thread. The status accessors are polled every time the Settings Rules
tab opens, so delegating to it blindly meant that merely opening
Settings would pop a password prompt the user never asked for. The
manager now tracks whether a launch was genuinely triggered (by the
provider's locked-file path or the enable-toggle) and answers "not yet
tried" without touching the launcher until then.

The same commit fixes `helper_launchable()` to count not-yet-attempted
as launchable, matching the Windows twin - it previously reported
`locked_file_backup_degraded: true` before any launch attempt even with
the setting on and the sidecar present, contradicting its own doc
comment.

Regression test: `reading_status_never_triggers_a_consent_prompt` reads
every status accessor three times and asserts the launcher never goes
in-flight.


---

**Note on CI:** `ci.yml`, `coverage.yml` and friends are declared `on:
pull_request: branches: [main]`, so a PR whose base is a feature branch
gets NO checks beyond the PR-title gate. That is why every gate in this
description was run locally on this machine and reported verbatim rather
than pointed at a green CI run. Retargeting this PR to `main` after the
parent PRs merge will trigger the real matrix.


---

# Rebased onto `main` after #196 merged (`a5f105e`)

Re-cut from `origin/main` and re-applied rather than rebased through the
squash-merge, so the diff is only my own work. The `cargo fmt` hunk in
`crates/driven-apfs/tests/broker_integration.rs` that this PR previously
carried is **gone** - it landed upstream, so that file is no longer
touched here.

**Compatibility with #196's security review: verified, no changes
needed.** I never called the removed `Control::DeleteSnapshot` /
`client.delete_snapshot`; the APIs this wiring uses are all intact
(`client` still `#[cfg(target_os = "macos")]`,
`HelperClient::shutdown()` present, `ApfsBrokeredProvider::new(Arc<dyn
HelperLauncher>, VssMode)` unchanged, `launch.rs` untouched). One
unrelated fix was needed: main's proxy-credential-redaction PR added a
second `SettingsDto` initializer that needed the new `macos` field.

## New: a bug that would have made this feature 100% non-functional

**The session socket path did not fit in `sockaddr_un`.** Darwin's
`sun_path` is 104 bytes, and macOS points `TMPDIR` at a per-user
`/var/folders/<xx>/<~20 chars>/T/`. My `runtime_dir()` used
`std::env::temp_dir()`, producing:

```
/var/folders/s8/r0zq_8jj4nn8kzjdt8p3453c0000gn/T/driven-apfs/driven-apfs-<32 hex>.sock
= 110 bytes  (limit 104, so 6 over)
```

I found this by running the real broker binary, which died with `path
must be shorter than SUN_LEN` before serving a single request. This
would have happened on **every** Mac. No test caught it because nothing
in the suite binds a socket - my tests only checked the path's *shape*.

Fixed by moving the runtime dir to `/tmp/driven-apfs-<uid>` (mode
`0700`), which brings the full path to 70 bytes. `/tmp` is
world-writable but sticky and the per-uid dir is `0700`, so another user
can neither read the socket nor replace the dir; if they pre-create it,
it is owned by them and the broker's own "socket parent must be owned by
the peer uid with mode 0700" check refuses to serve - fails closed. Two
regression tests added (`socket_path_fits_in_sockaddr_un`,
`runtime_dir_avoids_the_long_per_user_tmpdir`).

## The end-to-end gap is now closed - the broker chain WORKS

Using the merged broker binary, a root-owned bundle dir, and a
co-installed client harness, the full chain ran on this macOS 26.6
machine:

```
== 1. create an APFS local snapshot (UNPRIVILEGED, tmutil) ==
   created snapshot date = 2026-07-29-132651
== 2. connect to the root broker (client verifies peer is uid 0) ==
   connected, peer authenticated as root
== 3. broker-mount the snapshot for /System/Volumes/Data ==
   mounted at /private/var/run/driven-apfs-mounts-96237/m0
== 4. read the probe file THROUGH the snapshot (un-elevated) ==
   mapped -> /private/var/run/driven-apfs-mounts-96237/m0/Users/pmaxh/driven-apfs-e2e-probe.txt
   READ OK: 54 bytes, first line = "PROBE CONTENT written at Wed Jul 29 13:26:50 CDT 2026"
== 5. unmount everything ==   unmounted
== 6. delete the snapshot (UNPRIVILEGED post-#196) ==   deleted
```

Zero leftover snapshots, zero leftover mounts afterwards. The firmlink
mapping resolved `~/…` to `/System/Volumes/Data` correctly, and #196's
new root-owned audit log was written as designed.

## BLOCKER for shipping: the install layout defeats the broker's own
guard

#196's broker refuses to serve if the helper's directory is writable by
the client uid. Measured on this machine:

| App | `Contents/MacOS` owner | Broker would serve? |
|---|---|---|
| AltTab, Audacity, Chrome, Claude, Discord, Docker, GIMP (all DMG
drag-installs, in `/Applications`) | `pmaxh` (me) | **NO** |
| Cloudflare WARP (pkg), GarageBand (App Store), system apps |
`root:wheel` | yes |

I confirmed the refusal by running the real broker from a user-owned
dir:

```
driven-apfs-helper: refusing to serve: the helper's directory is writable by
the client user, so co-installation cannot authenticate a peer
```

**Driven ships a `.dmg` and the README tells users to drag it to
`/Applications`, which produces a user-owned bundle. So on a stock
install this feature is inert**: the user toggles it on, approves the
administrator prompt, and the broker exits at startup. Note the guard's
own comment anticipates `~/Applications` / `~/Downloads`; the empirical
reality is broader - plain `/Applications` drag-installs are user-owned
too.

Per the brief I am **not** working around this - it is a deliberate
security property, and the honest fix is packaging (a `.pkg`, or a
post-install step that leaves the bundle root-owned), which is out of
scope here. It is documented in DESIGN s5.3.2 and, user-facing, in the
README (PR #205).

One related rough edge, not fixed here: `osascript` returns success as
soon as the detached broker is spawned, so the launcher records `Ready`
even when the broker then exits immediately. The user gets a password
prompt and a "working" status while nothing mounts. Worth a follow-up
(probe the socket before declaring `Ready`).

## Gates re-run after the rebase (all local - see CI note below)

`cargo test --workspace` 41 suites / exit 0 - `cargo clippy --workspace
--all-targets -D warnings` clean - `cargo fmt --all --check` clean - UI
lint 0 errors, 535 tests, build + `format:check` clean. 14 apfs-specific
Rust tests pass.


---

# Correction: the helper-dir refusal is advisory now (#211), and the
docs above are superseded

**Everything above that says this feature is "inert on a stock install"
is no longer true** and has been rewritten in DESIGN s5.3.2. #211
downgraded the broker's co-installation helper-dir check from fatal to
advisory, so **locked-file backup works on a normal drag-installed
`.app`**. I am leaving the earlier text in place for the audit trail
rather than silently editing it; DESIGN and the README (in #205) carry
the accurate version.

The accurate story, not overstated in either direction:

- The co-installation check ("the caller's executable sits next to
mine") is only strong if the peer cannot write to the helper's
directory. On a drag-installed bundle they can, and that is the normal
case, not the exotic one - measured across 7 real apps, including in
`/Applications`.
- So the broker now **serves** and writes a `DEGRADED:` line to its
root-owned audit log naming the directory and the uid.
- The residual risk is small because the attacker is already the same
uid (`getpeereid` is not bypassable by planting a binary), and what
defeating co-installation buys them is a read-only, `nosuid`, `nodev`,
ownership-preserving mount of an allow-listed volume at a broker-chosen
mountpoint - a point-in-time copy of files that uid could already read,
with TCC still applying to their own process.
- The load-bearing checks (peer uid, volume allow-list, snapshot-name
validation, mount options) are untouched.
- A `.pkg`-style root-owned install restores the check to full strength
and silences the `DEGRADED:` line. It is an improvement, not a
prerequisite.

My empirical measurements stand and are what motivated the downgrade;
only the consequence changed.

## Also fixed here: `Ready` no longer means "a process was spawned"

This was the rough edge I flagged, and it is small enough to fix rather
than park (~45 lines plus tests, contained entirely in
`src-tauri/src/apfs_helper.rs`).

`osascript` exits 0 as soon as the consent prompt resolves and the shell
backgrounds the broker, so the launcher recorded `Ready` from the
**spawn alone**. A broker that then died immediately - failed
pre-flight, bad sidecar, unbindable socket path - left the user with an
administrator prompt followed by a healthy-looking status while nothing
ever mounted. That is precisely the silent shape my `sun_path` overflow
produced, so this is the difference between a bug found in minutes and
one found never.

The manager now probes the socket before passing `Ready` upward:

- **Connect-and-drop liveness, not existence.** A dead broker leaves its
socket *file* on disk; connecting to that stale file fails
`ECONNREFUSED`. A test asserts exactly this distinction by binding a
real `UnixListener`, probing it live, dropping it, and probing the
leftover file.
- **An unbacked `Ready` reports `Pending`** (a transient skip, retried
next cycle) for a 15s grace window covering the bind, **then
`Disabled`** - which makes `helper_launchable` false and surfaces as
degraded in the UI, rather than an eternal `Pending` nobody can
diagnose. A warn line names the socket.
- **Probing stops at the first success**, so a healthy session costs a
couple of connects, not one per status poll.
- It is not authentication and does not pretend to be - the client's own
root-peer check does that.

Three new tests
(`socket_probe_sees_a_live_listener_and_rejects_a_stale_file`,
`a_ready_launcher_without_a_live_socket_is_not_reported_ready`,
`an_unbacked_ready_becomes_disabled_once_the_grace_window_expires`),
bringing the apfs-specific Rust tests to 17.

One thing deliberately NOT asserted, and the comment in the test says
so: the end-to-end `Disabled -> helper_launchable false -> degraded`
mapping. Reaching it needs `launch_attempted` set, which makes the
accessors call the real launcher, whose `NotAttempted` transition spawns
a genuine osascript consent prompt. A test that popped a password dialog
would be worse than the coverage is worth;
`reading_status_never_triggers_a_consent_prompt` guards that boundary
instead.

## Gates after these changes

`cargo test --workspace` 41 suites / exit 0 - clippy `-D warnings` clean
- `cargo fmt --all --check` clean - UI lint 0 errors, build +
`format:check` clean. The previous commit's full CI matrix (17 checks
incl. all three `cargo test + clippy` OSes, all three `tauri compile`,
Coverage, Chaos, CodeQL) passed green; this push re-runs it.


---

# Rebased onto current `main` (`2df3540`), and reconciled with #209's
docs refresh

`a5f105e` is an ancestor of `main`, so this was a plain `git rebase
origin/main`, not another re-cut. Two conflicts, both trivial: an
import-list collision in `ui/src/ipc/commands.ts` (kept both sides) and
`Cargo.lock` (regenerated from main's).

**#209 landed a docs refresh while this was in flight, and it describes
this feature as not existing.** Since #201 is what makes those
statements false, correcting them belongs here, not in a follow-up:

- **Note 41** said "a macOS APFS-snapshot bypass ... exists as a broker
crate but is not yet wired into the backup path, so it does not do
anything for a user yet". Rewritten: macOS now has the equivalent behind
the opt-in Settings > Rules toggle, off by default, and it does nothing
for an FDA denial. I also fixed a contradiction I briefly introduced
there ("neither macOS nor Linux backs a file up through a lock today"
immediately followed by "macOS additionally has ..."); the note now
leads with what macOS does and ends with Linux having no equivalent.
- **The feature bullet** ("an APFS-snapshot bypass ... is in development
for macOS but not yet available") now describes the shipped opt-in.
- **The FDA section's cross-reference** ("a macOS APFS-snapshot
equivalent is in development, see the table above") now points at the
real section below it.

New README section **"macOS locked-file backup (APFS snapshot,
opt-in)"**, with the two-row table splitting `local.file_locked` (fix:
the toggle) from `local.permission_denied` (fix: FDA, nothing else), a
note that it works without Time Machine configured (verified on
hardware), and the drag-install advisory blockquote in its corrected,
non-alarmist form.

I deliberately did NOT re-add my own "not a substitute for Full Disk
Access" paragraph: #209 already added an equivalent one, better placed,
including the CVE-2020-9771 history. Duplicating it would have been
noise.

## Note on the local test run

`cargo test --workspace` cannot complete on this machine any more, and
it is not this branch's doing: #200's new `driven-backend` crate has two
keychain tests
(`account_creds_prefer_the_keychain_record_and_fall_back_to_the_env_seam`,
`build_store_returns_a_drive_store_once_a_refresh_token_is_stored`) that
block on an invisible macOS `SecurityAgent` prompt (I confirmed the
process is running while they hang). They pass on CI, and this branch
does not touch `driven-backend` - `git diff origin/main --name-only`
returns zero files under it.

So the local run reported here is `cargo test --workspace --exclude
driven-backend`: **43 suites, exit 0**, including all 13 `apfs_helper`
tests and the 4 settings tests. `driven-backend` is covered by CI's
macos/ubuntu/windows matrix on this PR.

Other gates after the rebase: clippy `-D warnings` clean, `cargo fmt
--all --check` clean, UI lint 0 errors, 545 tests, build +
`format:check` clean.


---

# Rebased onto `main` with #211, #207 and #209 (`65010ac`)

Plain `git rebase origin/main`, no conflicts.

## Re-ran the full end-to-end against the merged #211 broker, from a
USER-OWNED directory

This is the case that previously refused to serve, i.e. exactly what a
DMG drag-install produces. It now works end to end:

```
=== helper dir (user-owned, as a drag-install produces) ===
pmaxh:wheel mode=755 .../e2e/userowned
broker up (it now SERVES from a user-owned dir)

1. create an APFS local snapshot (UNPRIVILEGED, tmutil) -> 2026-07-29-143527
2. connect to the root broker                           -> peer authenticated as root
3. broker-mount the snapshot for /System/Volumes/Data   -> /private/var/run/driven-apfs-mounts-37329/m0
4. read the probe file THROUGH the snapshot             -> READ OK: 54 bytes, content correct
5. unmount everything                                   -> unmounted
6. delete the snapshot (unprivileged)                   -> deleted
```

Zero leftover snapshots, zero leftover mounts. So the downgrade achieves
what it set out to: the feature is live for ordinary installs, and my
DESIGN wording matches the merged implementation's audit string verbatim
(`DEGRADED: helper directory {} is writable by uid {}`).

One caveat on my own evidence: the script's audit-log dump came back
empty on this run because the per-session mount root is torn down before
it reads it, so I did **not** independently observe the `DEGRADED:` line
in this particular run - I am relying on the merged code path for that,
not on my own observation. Everything else above I watched happen.

## The earlier ubuntu CI failure was not this branch

The previous run showed `cargo test + clippy (ubuntu-latest)` failing on
`driven_backend::tests::build_store_needs_reauth_for_a_drive_account_with_an_empty_keychain`
with `org.freedesktop.secrets was not provided by any .service files`.
That branch was based on `2df3540`, which predates #207 - the test had
no mock guard yet. On the current base it carries `let Some(_g) =
keychain() else { return };` and passes. Everything else in that run was
green, including all three `tauri compile` jobs and `cargo test +
clippy` on macOS and Windows.

## Local gates now run WITHOUT the `--exclude driven-backend` workaround

With #207 in the base I re-ran the real thing and watched for the prompt
while it ran:

- `SQLX_OFFLINE=true cargo test --workspace` - **48 suites, exit 0, no
exclusions**
- Both formerly-hanging keychain tests now pass against the in-memory
store
(`build_store_needs_reauth_for_a_drive_account_with_an_empty_keychain
... ok`,
`account_creds_prefer_the_keychain_record_and_fall_back_to_the_env_seam
... ok`)
- `pgrep SecurityAgent` was empty throughout - **no keychain prompt was
raised**

Also green: clippy `-D warnings`, `cargo fmt --all --check`, UI lint 0
errors, 552 UI tests, build, `format:check`.
@pmaxhogan
pmaxhogan deleted the branch feat/macos-apfs-wiring July 29, 2026 21:14
@pmaxhogan pmaxhogan closed this Jul 29, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in Driven Jul 29, 2026
pmaxhogan added a commit that referenced this pull request Jul 29, 2026
…denied (#216)

Reopened: the original #205 was auto-closed when its base branch
`feat/macos-apfs-wiring`
was deleted on #201's merge. Same content, rebased onto `main` so it now
gets the full CI
matrix (a stacked PR only ever ran the title check).

Completes the macOS locked-file story started by #195 (classification),
#196 (the APFS
snapshot broker) and #201 (wiring). Those handle files a *lock* blocks;
this handles the
other half - files macOS PRIVACY blocks.

## What it does

When a cycle skips files with `local.permission_denied`, the UI surfaces
a dismissible
hint explaining that macOS privacy protection (TCC) is blocking them,
with a button that
opens System Settings directly at the Full Disk Access pane.

The deep link is
`x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles`,
verified four ways on macOS 26.6 rather than assumed:

1. Opening it lands on a window titled exactly `Full Disk Access`.
2. Control: the same URL with a bogus anchor lands on plain `Privacy &
Security`, so the
   anchor is load-bearing rather than a false positive.
3. It still navigates correctly when System Settings is already open on
another pane.
4. Because `openUrl` goes through the plugin's scope rather than
`open(1)`, the glob was
checked directly: it matches the target and rejects
`file:///etc/passwd`,
   `https://evil.example` and `x-apple.systemprefsX:y`.

## Two bugs caught that tests could not have

- **`opener:default` would have silently blocked the link.** Its scope
permits only
`mailto:`/`tel:`/`http`/`https`; a custom scheme throws at runtime while
every mocked
test passes. Fixed with a scoped `opener:allow-open-url` entry,
confirmed present in the
  generated `capabilities.json`.
- **Reading helper status fired the admin password prompt.**
`launch_status()` is not a
pure read - its `NotAttempted -> InFlight` transition is what spawns
consent. Status
accessors are polled whenever the Settings tab opens, so merely opening
Settings would
  have asked for a password. Fixed with a regression test.

## The accuracy point this PR exists to get right

A snapshot cannot substitute for Full Disk Access, and no doc here
implies otherwise. The
`-o noowners` bypass was CVE-2020-9771, is patched, and is now an
EDR-flagged signature -
Driven never uses it. So the README carries a table splitting
`local.file_locked` (fix:
the APFS toggle) from `local.permission_denied` (fix: FDA, and nothing
else will do).

Also documented: because Driven ships unsigned, a granted FDA can
silently lapse when the
binary is rebuilt, since macOS ties the grant to code identity.

## Reconciled with #209

#209's docs refresh landed mid-flight saying "there is currently no
in-app guided flow -
planned for a future release". Rather than adding a second FDA section,
this edits #209's
in place, adds the grant steps and the unsigned-binary caveat it lacked,
keeps its
"not a substitute for FDA" paragraph, and flips its `DRAFT - do not
uncomment` roadmap
bullet live.

Banner dismissal is per-session rather than persisted: `localStorage`
appears nowhere in
`ui/src`, and the condition is genuinely unresolved until FDA is
granted.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant