paranco

One permission, spent on one folder at a time.

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.

Source voice-memos One identifier from the compiled-in list. Reading its folder needs Full Disk Access.
Route Paranco.app lifts m4a mp3 wav aiff aifc caf, one way, and writes nothing back.
Destination ~/Recordings An ordinary folder of your choosing. Whatever reads it needs nothing.
Onepermission, held by Paranco.app. The program that reads the result is granted nothing.
One wayfrom source to destination. Nothing is ever written into the source.
Fixed pathsa route names one source and one destination. There is no way to say "everything under Library", and there will not be.

The problem

The permission
is bigger than
the need.

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

Two things
macOS does
that shaped
this tool.

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.

01The access is charged to whoever asked

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.

02A refusal that arrives as an empty folder

Asked through System Events, a protected folder does not say no. It reports itself as empty.

An ordinary folder holding sixteen files, asked through System Events 16 The count it has.
The Voice Memos library, asked the same way, from a process without the permission 0 Not an error. Not a refusal. Zero.

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.

AnswerWhat it means
readableThe folder can be listed, and here is how many files are in it. Zero here is a real zero.
refusedThe folder is there and macOS is not letting this process list it. The message names the folder and the setting to change.
missingThere 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

A list, not
a file, decides
what can be read.

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.

The list is compiled in

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.

A destination is checked before it is used

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.

The copy itself takes no shortcuts

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.

What a rogue write still cannot do

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

Routes, and an
agent that
runs them.

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.

  1. 01

    Build the application

    ./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.

  2. 02

    Give Paranco.app Full Disk Access

    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.

  3. 03

    Open Paranco.app

    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.

  4. 04

    Install the agent from the window

    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
Worth knowing before scripting it: run from a terminal, 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

What it will
never do.

Write into a source

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.

Offer Mail or Messages as sources

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.

Phone home

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.