Skip to content
Development

When your app can't take back a permission it already gave

By Victor Da Luz
taurirustsecuritydev-loggreenhouse

I found a permission bug in Greenhouse this week that doesn’t have a clean fix. Not “clean” as in “I didn’t try hard enough” - clean as in the underlying API genuinely doesn’t support undoing what it lets you do, and the fix is a UX tradeoff, not a code trick.

The problem

Greenhouse is a desktop app (Tauri v2 + Rust + Svelte) where the user picks a folder on disk - their “vault” - and the app manages projects inside it. To preview audio files in the app, the webview needs read access to that folder. Tauri’s answer for “let the webview read this one directory” is the asset protocol’s runtime scope: call allow_directory(path, recursive) once you know the path, and convertFileSrc starts working for anything under it.

Greenhouse also lets you switch vaults without restarting - point the app at a different folder mid-session. Should be easy: grant scope to the new folder, done.

Except I went looking for the other half of that API - something like forbid_directory or revoke - so switching away from a vault would also take back read access to it. It doesn’t exist. I read the vendored Tauri source to be sure: the scope is backed by an allow-set and a forbid-set, and allow_directory only ever inserts into the allow-set. Nothing removes from it, for the lifetime of the process.

Why this approach

My first instinct was forbid_directory(old_path) on switch - fight fire with fire. That has its own trap: forbidden patterns take precedence over allowed ones in Tauri’s is_allowed check, and there’s no un-forbid either. Forbid vault A once, and switching back to A later in the same session is permanently broken - you’d have to restart anyway, just at a worse time (when the user is trying to go back to a vault they already trust).

So the real options were: accept that every visited vault stays webview-readable for the session and document it, grant access file-by-file instead of directory-by-directory (a lot more IPC surface for very little benefit in a single-user local app), or make a vault switch take effect on restart instead of live. I went with restart-to-switch. A fresh process starts with zero scope grants - that’s the one moment the accumulation problem doesn’t exist at all, because nothing has been granted yet.

Implementation

The command that sets the vault root now captures what the previous root was before doing anything else, and compares it to the new one. If they’re the same (first-time setup, or picking the vault you’re already in), it activates immediately like before. If they differ, it’s a genuine switch: the new path gets persisted to settings so the next launch picks it up, but the actual scope grant and in-memory activation are skipped entirely. The command returns whether a restart is needed, and the frontend shows a “restart to switch” panel instead of just closing the dialog.

The restart itself is one line - tauri-plugin-process’s relaunch() - gated by a capability scoped to just that one permission, not the broader default that also grants exit.

Gotchas

Verifying this without a human clicking through it was its own puzzle. The switch flow starts with a native OS folder picker, which a WebDriver literally cannot touch - no DOM to find. And the restart command, once it fires, kills the exact process your WebDriver session is attached to, so there’s no “it succeeded” response to read; the process that would send it is gone.

The trick that worked: invoke the restart command directly over IPC (bypassing the picker by calling set_vault_root with a hardcoded path), then read the shape of the failure. A connection-level error - the fetch itself failing, not a JSON error body - means the command executed and took the process down with it. An actual JSON response complaining about permissions would mean the capability was misconfigured. I confirmed the “it really restarted” half independently with a plain ps aux check for a new PID. That gave me real confidence in the plumbing (plugin registered, capability wired, command reachable) without ever needing the native dialog in the loop for that part.

What I couldn’t script, and didn’t try to: does the app actually come up on the new vault after a real restart triggered from the real UI. That’s the one place I stopped scripting and clicked through it by hand instead of hunting for a way to fake it.

Results

Switching vaults now shows a clear “needs a restart” prompt instead of quietly switching live and leaking read access to every vault you’ve ever opened in that session. It’s a small UX regression - switching used to be instant - in exchange for an actual security property holding. Worth it for what’s ostensibly a local single-user app? Debatable. But once I’d read the source and confirmed there was no revoke, “accept and document forever” felt worse than “cost one restart.”

Related reading

Development

Three doors that were never locked

A null CSP, a config value joined onto a filesystem path unchecked, and a vault picker that would accept your home directory - none broken yet, all unlocked.

Read
Development

Two notes nobody ever saw

Greenhouse saved a capture note and a handoff note faithfully, and showed neither: one had no field in the wire type, the other had a working command nobody called.

Read
Development

The scan that offered to import itself

An import scanner that found the app's own scaffolding, then re-offered a folder it had just imported - two versions of the same missing conversation between layers.

Read