My Scheduled Job Died With 'Operation not permitted' — The File Was Fine, the Process Wasn't
I have a Mac mini in a closet that runs a small Python scanner on a schedule. The code lives on a network volume, the job runs under launchd, and it had been fine for months. Then one morning it was in a loop, restarting every few seconds, spitting this:
/bin/bash: /Volumes/turgay/.../start_scanner.sh: Operation not permitted
Exit code 126. Over and over. The error log had grown to a few thousand lines of it.
Operation not permitted is the message you get when you know the file is there but something is refusing you anyway. I did what anyone does first: I ran it by hand.
ls -la /Volumes/turgay/.../start_scanner.sh
# -rwx------ 1 turgaycoruh staff 1234 ... start_scanner.sh
bash /Volumes/turgay/.../start_scanner.sh # works perfectly
Owner is me. Mode is 700. It runs. And yet the scheduled job — also running as me — couldn't touch it.
This post is about the three hours it took to stop looking at the file and start looking at the process.
The false trails
If you only stare at the file, every explanation is wrong in the same way. Here's what I ruled out:
-
The mount. The SMB volume was mounted, ping was sub-millisecond, and
Findercould browse it. I even had it list the project folder's contents and read a file's size. Directory enumeration worked. -
File ownership and mode.
700, owned by me, on a volume mounted as me. -
The path. This one was real and I fixed it first — the plist still pointed at a stale
/Volumes/turgay-1/..., and I corrected it to/Volumes/turgay/.... Didn't help. - The NAS. The drive served other clients fine; the human sitting at the machine could open the files in Finder without any trouble.
The trail that actually mattered came from a difference I almost ignored:
os.listdir("/Volumes/turgay/...") # OK — 44 entries
open("/Volumes/turgay/.../README.md") # EPERM: Operation not permitted
Listing worked. Opening content did not. That's not how ordinary POSIX permissions fail — if I can't traverse or read a directory, I can't list it either. Something was granting me metadata access and denying content access. That's not a file system permission. That's a sandbox talking.
The line that named the culprit
I grepped the system log while the job failed and found this:
sandbox_extension_issue_file failed for /Volumes/turgay/...: 1 (Operation not permitted)
sandbox_extension_issue_file. That's macOS's TCC (Transparency, Consent, and Control) machinery — the same layer behind "App X would like to access your Documents" and Full Disk Access. It was failing to issue a sandbox extension to my process. The process didn't have permission to be handed permission.
Once I knew it was TCC and not POSIX, the test was obvious. I checked classic Full-Disk-Access-protected paths from inside the failing job:
~/Library/Mail → BLOCKED
~/Library/Safari → BLOCKED
~/Library/Messages → BLOCKED
/Volumes/turgay/... → BLOCKED
a local workspace path → OK
Every FDA-protected location denied, normal local paths fine. That's the signature of a process running without the Full Disk Access grant.
Why Finder worked and my job didn't
The thing that confused me for the longest time: the human could open those files. The gateway process (a Node process on the same machine) could read them too after a restart. Only my launchd job was blind.
The reason is that TCC grants attach to a process and its descendants, not to a user. Concretely:
- The Node process that ran my gateway had been granted Full Disk Access. Everything it spawned inherited that.
- My
launchdjob ran/bin/bash, which ranpython3./bin/bashhad no grant. So the whole tree — bash and the Python it launched — was denied.
Same user, same machine, same files. Different grant, because the ancestor process was different. launchd runs jobs as children of itself, so the grant has to be on the interpreter that launchd actually launches.
I proved this with two throwaway jobs: one spawned through node (granted → read fine), one through /bin/bash (denied → EPERM). Same payload, opposite result. The grant was the only variable.
The fix
Grant Full Disk Access to the interpreter that runs the job — in my case /bin/bash:
- System Settings → Privacy & Security → Full Disk Access.
- Add
/bin/bash(Cmd+Shift+G to type the path) and toggle it on. - Restart the process — a grant only takes effect for processes started after it's set.
After the grant, the whole chain worked: launchd → bash → bash → python, reading the volume, scanning normally. I'd also left a small watchdog job polling for the grant and kicking off the real job the moment it appeared — that fired on its own and removed itself, which was a nice confirmation that the grant landed.
The parts worth remembering
Three things I'd tell myself before starting:
Operation not permittedon a file you can see is a process problem, not a file problem. If listing works and opening fails, stop looking at the file. Check whether the process has the entitlement.Test the access that's failing, not a similar one.
lssucceeding told me nothing — it exercised a different code path thanopen. I wasted time "confirming" the mount was fine with a command that only proved directory listing was fine. Assert on the operation that actually breaks.Grants live on a binary path, and binaries move. I first granted Full Disk Access to the Homebrew
nodebinary — which is fine until the next Node update changes the versioned path and silently breaks everything again. Interpreter grants like/bin/bashare more stable, but the general rule stands: the moment a grant is tied to a path, a version bump is a future outage. Write down which binary you granted, or you'll rediscover this the hard way.
The honest summary: the code, the mount, the NAS, and the permissions on the file were all innocent. The bug was one checkbox I never knew my scheduled job depended on — and the reason it took hours is that every symptom pointed away from the process and toward the file.
Has anyone else chased a TCC/Full Disk Access ghost on a background job? I'd be curious whether people grant the interpreter, wrap the job in a signed launcher, or just give up and run the thing from a login session instead. Drop your setup in the comments — this feels like one of those areas where everyone has a slightly different workaround.
Part of my Building in Public series — previously: the exit rule that counted the wrong thing, the drawdown breaker watching a number that never moved, and the margin guard that read zero through four separate bugs.
Top comments (2)
Great write-up. "Listing works, opening fails" is the most reliable TCC tell I know of.
To your question: we stopped granting the interpreter. Full Disk Access on /bin/bash covers every launchd job that starts bash, including any LaunchAgent something else drops into ~/Library/LaunchAgents later, so the grant is effectively machine-wide. What has worked better for us is a tiny dedicated launcher: a few-line compiled binary that spawns your one script as a child and waits for it, pointed at directly as ProgramArguments[0]. Grant FDA to that binary only; its children inherit it, nothing else does.
Two things that bit us on that path:
bash -c.Running
log stream --level debug --predicate 'subsystem == "com.apple.TCC"'while the job fires shows which binary TCC is actually evaluating, which takes most of the guessing out of it.Disclosure: I'm on the Auten team. We build desktop automation, so macOS privacy grants are a daily topic for us.
The other headache with granting the interpreter directly is LaunchAgent throttle loops. If the volume remounts or drops during a network hiccup, launchd restarts the job immediately, hits exit code 126 or ENOENT, and dumps thousands of lines into the system log before you notice. Putting an explicit read check on the target path right at the top of the entrypoint and exiting with 75 or sleeping before exit prevents the restart thrash. I ran into this on a background sync worker where a brief network disconnect turned into an eight-gigabyte log file overnight.