macOS keeps some folders closed to any program without Full Disk Access. The Voice Memos library is one of them. paranco is a small application that holds that permission so nothing else has to: it copies files one way, along routes you write down, out of a protected folder and into an ordinary one that anything can read.
macOS 14 or later. Built from the repository with one script; there is nothing to download.
The problem
Some folders on a Mac are protected. A program that wants to read one of them needs Full Disk Access, and Full Disk Access is not a permission on a folder. It is a permission on everything the system protects, opened at once.
The need is usually one folder. A program that wants to read the Voice Memos library needs Full Disk Access to do it directly, and Full Disk Access granted to that program is granted to everything it loads: an interpreter or a runtime, every package installed beside it, and whatever those packages load, now and after their next update. None of that asked for the permission and all of it would hold it.
paranco is the smaller thing to grant. It has one job, reads one folder per route, and delivers into a folder that needs no permission. The program that consumes the files is given nothing, and keeps working as it did.
What was found
Both were measured, and they decide the shape of the tool: the first is why paranco is an application and not a script, the second is why it tells an empty folder from a refused one.
macOS charges an access to the process that asked for it. Drive Finder or System Events from a terminal and it is the terminal's permission that gets tested, not theirs. A permission cannot be borrowed by driving something that has it.
So the holder has to be an application with its own identity, started by
launchd or by a person. That is what Paranco.app is. The agent that runs
the routes on their own is not a second binary: launchd runs the
application's own executable with --agent, so the grant is
given once and used by the window and by launchd alike. The same rule cuts
the other way for the command line, which is the same core run from a
terminal and is charged the terminal's permission. On a protected folder
paranco check says refused even after the application was
granted, and that is macOS attributing the access correctly, not a bug in
either.
Asked through System Events, a protected folder does not say no. It reports itself as empty.
Zero is a plausible answer for a library, which is what makes it the most misleading one available.
A program that stops at that answer tells somebody with a library full of recordings that they have none, and nothing on screen says anything went wrong. So every folder paranco looks at gets one of three answers, and a refusal carries the sentence a person needs, because they have usually just granted a permission and want to know whether it took.
| Answer | What it means |
|---|---|
| readable | The folder can be listed, and here is how many files are in it. Zero here is a real zero. |
| refused | The folder is there and macOS is not letting this process list it. The message names the folder and the setting to change. |
| missing | There is no folder at that path at all. |
The separation is possible because macOS allows a program to ask whether a protected folder exists. It is the listing that is refused, and that is what makes the two cases tellable apart.
The security model
Four short facts explain the whole of it: what the list is, what a destination has to be, what the copy does, and what a program running as you can still get away with.
A route names its source by identifier, never by path. The identifier
is resolved only against Source.builtIn, a list of
folders written into the source code. Today it holds one entry,
voice-memos. Nothing on the machine can add to it: not a
file, not the window, not the command line. Adding a folder means
editing the source and rebuilding, and with an ad-hoc signature a
rebuild also means granting Full Disk Access again. That cost is the
point: a reviewer sees the folder being added, and a person consents
to it again.
It has to be a real folder inside your home, outside
~/Library, not inside any source, and on this Mac's own
disk rather than a network or removable one. No symbolic link is
allowed anywhere along its path, and no . or
.. component either. All of that is decided before a
single byte is written.
Each file lands in a fresh file, created with O_EXCL and
O_NOFOLLOW, private to you, with no execute bit and
nothing carried over from the original beyond its bytes, then renamed
into place. Anything already sitting at that name, a symlink
included, is replaced by the rename and never followed.
A program running as you can still write routes.json;
nothing on the filesystem stops that. What it buys is a choice among
the folders this build already allows to be read, and destinations
this build already allows to be written to. It does not buy a new
folder. The list is the boundary, not the file.
How it works
A route names a source by identifier and a destination folder, and says which files it carries. The source is resolved only against the compiled-in list. The destination is an ordinary folder, checked before anything is written to it, and whatever reads it afterward is granted nothing.
Routes live in a JSON file under Application Support rather than in preferences, so they can be read, diffed and edited with anything, and a route added from the window is the same route the agent sees.
Lifting a route copies every file it carries that is not already there. Same name and same size means already there. A file whose size differs from the copy is copied again, which is what happens when a recording that was still being written is seen a second time, finished. A file still growing between two looks is left alone until it stops.
./build.sh in the repository. SwiftPM builds the executable and
the script assembles Paranco.app around it, so the whole thing stays in git
as text.
System Settings, Privacy & Security, Full Disk Access, add Paranco.app. That is the only grant anything gets.
The signature is ad-hoc, which identifies the binary by its hash. A rebuild is a different application to the privacy list, and the grant has to be given again. That is the price of not having a Developer ID, not a fault in the build.
Pick a source from the list, Voice Memos today, and a destination folder
of your own, for example ~/Recordings. The window checks the
source and says which of the three answers it got, so a grant that did not
take is found here and not in a log a week later.
launchd runs the application's own executable with --agent,
every five minutes by default: each enabled route once, then exit. How many it
copied, how many failed and any refusal go to agent.log beside the routes. From
then on the destination folder fills itself, and whatever reads it needs no
permission at all.
# build it, then grant Paranco.app Full Disk Access ./build.sh open Paranco.app # the same core from a terminal paranco sources paranco add voice-memos ~/Recordings paranco routes paranco lift paranco agent status # a name and an extension list are both optional paranco add voice-memos ~/Recordings --name memos --ext m4a,wav
paranco check
is charged the terminal's permission, so a protected source reads as refused
there even after Paranco.app was granted. The window and the agent are the
ones to trust for a protected route. The command line is for routes the
terminal can already read, and for editing the list.
Design decisions
The permission it holds could write anywhere. The code writes to the destination and nowhere else. The record of what has already been lifted is the destination itself, same name and same size, so nothing is left behind in the protected folder, not even a ledger.
The list is short on purpose. Each entry is a place where the data belongs to the person and the protection is there to stop other software, which is the case this program exists for. Mail and Messages are not on that list and will not be added to it.
There is no network code in it: no update check, no crash reports, no telemetry, no endpoint to reach. This page makes no external requests either. No webfonts, no scripts, no analytics.