LaunchMon

LAUNCHD FIELD NOTES

LaunchAgent script gets “Operation not permitted”: launchd jobs and Full Disk Access

The script works when you run it in Terminal and fails under launchd, because Desktop, Documents, Downloads, iCloud Drive and external volumes are privacy-protected and Terminal’s permission does not carry over to the job. Move the files out of those folders, or give Full Disk Access to the program launchd actually runs, which is usually the interpreter, not your script.

The job’s error log shows something like one of these:

ls: /Users/you/Documents/reports: Operation not permitted
PermissionError: [Errno 1] Operation not permitted: '/Users/you/Documents/reports'

The wording matters. Operation not permitted is EPERM (errno 1). Ordinary file permissions fail with Permission denied, EACCES (errno 13), and chmod or chown can fix those. Apple’s Developer Technical Support puts it this way: an operation blocked by BSD permissions or ACLs fails with EACCES, and one blocked by something else, such as macOS privacy protection, fails with EPERM. Python reports both as PermissionError, so read the number in brackets. Changing the owner or mode of the folder will not fix EPERM.

Start in Terminal

These examples use a placeholder label. Substitute the exact Label from your own plist, not its filename.

1. Find the program launchd starts. That program is what macOS checks, not the script file:

launchctl print "gui/$(id -u)/local.example" | grep -E "program =|last exit code"
plutil -p ~/Library/LaunchAgents/local.example.plist

launchctl print shows program = , which is the Program key or, if there is none, the first item of ProgramArguments (the launchd.plist manual describes the same rule). For a job written as /bin/zsh followed by the script’s path, the program is /bin/zsh. A script listed on its own runs as the interpreter on its #! line: a script that starts with #!/bin/zsh, run directly, shows up in ps as /bin/zsh, with the script as an argument.

2. Check what Python really is. /usr/bin/python3 is a small stand-in that hands over to the Python from Xcode or the Command Line Tools, and the process that actually touches your files is a different file. Start it the same way and ask ps:

/usr/bin/python3 -c 'import time; time.sleep(5)' & ps -o comm= -p $!

On a Mac with Xcode on macOS 27 that printed /Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/Resources/Python.app/Contents/MacOS/Python, while Python’s own sys.executable reported /Applications/Xcode.app/Contents/Developer/usr/bin/python3. Use what ps prints: here, that Python.app. The same check works for Homebrew, pyenv and conda Pythons, and for #!/usr/bin/env python3, which under launchd’s short PATH finds /usr/bin/python3.

3. Give it Full Disk Access. Open System Settings › Privacy & Security › Full Disk Access, click the Add button, and in the file chooser press ⇧⌘G and type the path from step 1 or 2 (/bin/zsh, /bin/bash, or the Python.app path). Choose it, click Open, and make sure its switch is on. Files & Folders, the other section, only lists apps that have asked for access, in Apple’s wording, so Full Disk Access is the one you can grant by hand.

4. Run the job again. Restart it so a new process starts with the new permission, then read its log:

launchctl kickstart -k "gui/$(id -u)/local.example"

-k stops the running instance first, if there is one. If the script finishes cleanly, the last exit code is now 0.

The better fix: keep the job out of protected folders

Granting Full Disk Access to /bin/zsh lets every zsh script on the Mac read everything, not just yours. Apple’s DTS makes the same point: the system cannot tell what an interpreter does on behalf of one script from what it does for another, so the grant covers them all. Often you do not need it:

Apple announced on October 2, 2026 that it will add controls so that granting Full Disk Access takes “very explicit user action”. It gave no date or details, so expect the steps in step 3 to change, and prefer the fixes above where they work.

What to check next

Why it happens

macOS’s privacy system (TCC: transparency, consent and control) guards Desktop, Documents, Downloads, iCloud Drive, network volumes and removable volumes, plus data that only Full Disk Access unlocks, which Apple describes as including mail, messages and browsing history. It decides by asking which program is responsible for the access. When you run the script in Terminal, Terminal is responsible, and Terminal was allowed long ago, often after a prompt you no longer remember. When launchd runs the same script, the job has no Terminal behind it, so macOS judges the program the job runs, which has never been allowed. Apple’s DTS notes that if the system cannot ask you and has no earlier answer, the access is denied by default. The job carries on and gets EPERM.

User agents usually run in gui/<your uid>; system daemons run in system. The same label in different domains refers to different service registrations.

See it in LaunchMon

LaunchMon’s Executable row shows the program launchd starts, using the same rule as step 1, and Reveal Binary selects that file in Finder, so you know exactly which file to pick in Full Disk Access. Argument values are hidden for privacy, so use Open Plist to see the script’s path. Standard Error opens the job’s error log when the plist names one, the Live state panel shows the last exit code, and Kick-start runs the job again once the permission is in place. LaunchMon does not read your privacy settings, so it will not tell you a folder is protected; the EPERM in the log is the clue.

LaunchMon’s detail view for a service, showing its executable path with a Reveal Binary button, the last exit status, and Standard Output and Standard Error shortcuts.
LaunchMon with fictional demo services.

Download LaunchMon free trial

Related guides

References: Apple DTS: On File System Permissions, Apple Platform Security: controlling app access to files, Apple: change Privacy & Security settings on Mac, Apple: updates to Full Disk Access in macOS, Apple: creating launchd jobs. For commands on your macOS version, run man launchctl and man launchd.plist.