DEV Community

Vinh Nguyen
Vinh Nguyen

Posted on

A LaunchAgent gets `Operation not permitted` for `~/Documents` while Terminal works

The same zsh script could list ~/Documents when I ran it in Terminal. Started as a LaunchAgent, it failed with:

ls: /Users/administrator/Documents: Operation not permitted
Enter fullscreen mode Exit fullscreen mode

The LaunchAgent had the same user ID, the same $HOME, and the same script. That combination makes this look like a Unix permission problem. In this test it was not. The useful discriminator was the launch context: access succeeded from Terminal, failed from launchd, and still succeeded for a path outside the protected folder.

I reproduced this on macOS 15.6.1 (Darwin 24.6.0) with a LaunchAgent in gui/501. The probe was removed after the test.

Why chmod is the wrong first check

The obvious suspects were file ownership, a wrong home directory, or a job running as another user. The probe printed those facts before touching the files:

#!/bin/zsh

print -- "user=$(id -un) uid=$(id -u)"
print -- "home=$HOME pwd=$PWD"

/bin/ls "$HOME/Documents" 2>&1 | /usr/bin/head -5
/bin/cat "$HOME/Documents/vinh/working/CLAUDE.md" 2>&1 | /usr/bin/head -1

# Negative control: outside Documents
/bin/ls "$HOME/.pf004" 2>&1 | /usr/bin/head -5
Enter fullscreen mode Exit fullscreen mode

The two runs produced this difference:

Check Terminal LaunchAgent in gui/501
User / uid administrator / 501 administrator / 501
$HOME /Users/administrator /Users/administrator
ls ~/Documents Listed entries Operation not permitted
cat inside ~/Documents Read the file Operation not permitted
ls ~/.pf004 Listed entries Listed entries

The working directory differed, but the script used absolute paths under $HOME, so PWD=/ did not explain the denial. The negative control mattered more: the LaunchAgent could read another directory owned by the same user. Changing ownership or mode bits would not explain why only the launch context changed the result.

The owning layer is the privacy context

On this machine, the access decision was attached to how the process was launched, not just to uid 501. Terminal had a privacy context that allowed access to the user's Documents folder. The process started by launchd did not inherit that access.

That is why these observations can all be true at once:

  • id -u reports the expected user.
  • $HOME points to the expected home directory.
  • Ordinary files elsewhere under that home directory are readable.
  • A read under ~/Documents returns Operation not permitted.

The smallest diagnostic is therefore not another chmod. Run one identical read from the interactive application and from the scheduled process, then add a control path outside Desktop, Documents, or Downloads. If the user and path are correct, the control succeeds, and only the protected folder fails, investigate the macOS privacy context of the scheduled process.

A pipeline can hide the denial

My first probe contained this line:

/bin/ls "$HOME/Documents" 2>&1 | /usr/bin/head -5
print -- "status=$?"
Enter fullscreen mode Exit fullscreen mode

It printed Operation not permitted and then printed status=0.

In zsh, the default status of a pipeline is the status of its last command. head successfully printed the error text, so the pipeline looked successful even though ls failed. This is enough to turn a visible privacy denial into missing downstream data.

For a diagnostic script, enable pipeline failure before the probe:

set -o pipefail

/bin/ls "$HOME/Documents" 2>&1 | /usr/bin/head -5
print -- "status=$?"
Enter fullscreen mode Exit fullscreen mode

I checked the behavior with a minimal control: /usr/bin/false | /usr/bin/head -1 returned 0 by default and 1 with pipefail enabled. In a larger job, capture and handle the status immediately rather than letting another command overwrite $?.

What to change in an unattended job

If the job does not need a protected folder, move its required inputs and state to an unprotected application directory. The negative-control path under ~/.pf004 was readable from both launch contexts in this test.

If the job must read Documents, treat that as a separate permission requirement and test it from the actual LaunchAgent. A successful Terminal run is not proof that the scheduled process has the same access. I did not test Full Disk Access, a system LaunchDaemon, Desktop, or Downloads, so this result does not establish how those configurations behave.

The practical rule is narrow: same uid and same $HOME do not make Terminal and a LaunchAgent equivalent readers of ~/Documents. Compare the launch contexts, keep an outside-path negative control, and make the pipeline report the command that actually failed.

Top comments (1)

Collapse
 
reidmarlow profile image
Reid Marlow

This is the macOS bug shape that eats an afternoon because every Unix clue points the wrong way. I like the negative control outside Documents. For LaunchAgents touching protected folders, I usually check TCC context before chmod now.