Sequoia 'local network' permission failure from launch agent

I'm trying to invoke a 3rd party command line tool from a launch agent to connect to a server on my LAN. It seems impossible.

I have a little shell script that does what I need, and it works fine invoked in Terminal.app. The first time I run it that way I get permission prompts and I agree to them all. Subsequent invocations work.

Now I put a launch agent in ~/Library/Launch Agents. It does nothing more than invoke my shell script at some specific time daily. launchd launches it, but it fails to access the LAN, with a 'no route to host' error message.

The command line tool I'm trying to use is not a macOS-provided one, but one from MacPorts/HomeBrew (I tried both). It doesn't even matter which tool I'm using, I tried a very simple case of just using nc/netcat. If I use the macOS-provided nc, then I can access my LAN. If I install nc from MacPorts /HomeBrew, that nc cannot access my LAN.

This I've reproed on a literally brand new Mac, then updated to newest Sequoia (15.3.2), then done all I've described above.

I've ruled out DNS by working with raw IP addresses.

I've disabled gatekeeper with sudo spctl --master-disable.

I've tried using cron instead of launch agents, same results.

I've tried codesigning with codesign -dvvv /opt/homebrew/bin/nc, no help.

I've read TN3179 Understanding local network privacy.

In summary:

  • Terminal.app -> script -> macOS/brew nc -> internet/LAN = works
  • launchagent -> script -> macOS nc -> internet = works
  • launchagent -> script -> macOS nc -> LAN = works
  • launchagent -> script -> brew nc -> internet = works
  • launchagent -> script -> brew nc -> LAN = fails

How can I make that last case work?

Answered by DTS Engineer in 831898022

There are a bunch of factors that could lead to this problem but, after doing a bunch of testing here in my office, I suspect that this is a bug in local network privacy )-:

To start, I’m testing on macOS 15.3.2 (24D81), in a VM, restoring from a clean snapshot between each test.

I have a test tool that connects either via Network framework or BSD Sockets. The code is pasted in at the end of this email.

Note This relies on the helpers in Calling BSD Sockets from Swift.

The tool is signed with an Apple Development signing identity:

% codesign -d -vv Test778457
Authority=Apple Development: Quinn Quinn (7XFU7D52S4)

Note This is different from your case, because Homebrew uses ad-hoc signing. I was hoping that signing the code might help. It does not.

I have a launchd property list that runs my shell script:

% plutil -p com.example.Test778457.plist
{
"Label" => "com.example.Test778457"
"ProgramArguments" => [
0 => "/Users/quinn/Test778457.sh"
]
}

And a shell script that runs my tool:

% cat Test778457.sh
#! /bin/sh
set -e
log emit --subsystem "com.example.Test778457" --category "script" "start"
/Users/quinn/Test778457 bsd 192.168.1.39 80
log emit --subsystem "com.example.Test778457" --category "script" "end"

Note I used a shell script because that’s what you’re doing. Normally I recommend that folks don’t use shell scripts when user-controlled privileges, like Local Network, are involve. I actually tested this without a shell script just to see what’d happen. I saw some weird behaviour that I struggled to reproduce. However, in none of my tests did I see this completely fix the issue.

I load the launchd property list as an agent:

% launchctl load com.example.Test778457.plist

And run a test by starting it:

% launchctl start com.example.Test778457

I monitor the results in Console.

Here’s what I saw:

  1. I started the job as described above, with it configured to run the BSD Sockets code.

  2. This did not display a local network alert and the connection failed with EHOSTUNREACH. So the BSD Sockets code can’t connect.

  3. I edited the script to change bsd to nw.

  4. I started the job again. This time it presented the local network alert and successfully connected. So the Network framework code can connect.

  5. I reverted the edits from step 3.

  6. I started the job again. This time the BSD Sockets code was able to connect.

So, something weird is happening here. AFAIK local network privacy shouldn’t change behaviour based on which networking API you’re using, but clearly it does. Using a shell script is an added complication, but there’s nothing fundamentally wrong about that. And eliminating the shell script didn’t get this working anyway.

I recommend that you file a bug about this. Once you’re done, post back here with the bug number and I’ll add my owns findings to your bug.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"


import Foundation
import Network
import os.log
import System
let log = Logger(subsystem: "com.example.Test778457", category: "tool")
func runNetworkFramework(_ host: String, _ port: UInt16) -> Never {
log.log("will connect NW, host: \("\(host)", privacy: .public), port: \("\(port)", privacy: .public)")
let connection = NWConnection(host: .init(host), port: .init(rawValue: port)!, using: .tcp)
connection.stateUpdateHandler = { newState in
switch newState {
case .ready:
log.log("did connect")
exit(0)
case .failed(let error):
log.log("did not connect, failed, error: \(error, privacy: .public)")
exit(1)
case .cancelled:
log.log("did not connect, cancelled")
exit(1)
case .setup, .waiting(_), .preparing: fallthrough
@unknown default: return
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
log.log("did not connect, timeout")
exit(1)
}
connection.start(queue: .main)
dispatchMain()
}
func runBSDSockets(_ host: String, _ port: UInt16) -> Never {
log.log("will connect BSD, host: \(host, privacy: .public), port: \(port)")
do {
let s = try FileDescriptor.socket(AF_INET, SOCK_STREAM, 0)
try s.connect(host, port)
log.log("did connect")
exit(0)
} catch {
log.log("did not connect, failed, error: \(error, privacy: .public)")
exit(1)
}
}
func main() {
let arg1 = CommandLine.arguments.dropFirst(1).first.map { String($0) }
let arg2 = CommandLine.arguments.dropFirst(2).first.map { String($0) }
let arg3 = CommandLine.arguments.dropFirst(3).first.map { String($0) }
let arg4 = CommandLine.arguments.dropFirst(4).first.map { String($0) }
enum API: String {
case nw
case bsd
}
guard
let apiStr = arg1,
let api = API(rawValue: apiStr),
let host = arg2,
let port = arg3.flatMap( { UInt16($0) } ),
arg4 == nil
else { exit(1) }
switch api {
case .nw: runNetworkFramework(host, port)
case .bsd: runBSDSockets(host, port)
}
}
main()

There are a bunch of factors that could lead to this problem but, after doing a bunch of testing here in my office, I suspect that this is a bug in local network privacy )-:

To start, I’m testing on macOS 15.3.2 (24D81), in a VM, restoring from a clean snapshot between each test.

I have a test tool that connects either via Network framework or BSD Sockets. The code is pasted in at the end of this email.

Note This relies on the helpers in Calling BSD Sockets from Swift.

The tool is signed with an Apple Development signing identity:

% codesign -d -vv Test778457
Authority=Apple Development: Quinn Quinn (7XFU7D52S4)

Note This is different from your case, because Homebrew uses ad-hoc signing. I was hoping that signing the code might help. It does not.

I have a launchd property list that runs my shell script:

% plutil -p com.example.Test778457.plist
{
"Label" => "com.example.Test778457"
"ProgramArguments" => [
0 => "/Users/quinn/Test778457.sh"
]
}

And a shell script that runs my tool:

% cat Test778457.sh
#! /bin/sh
set -e
log emit --subsystem "com.example.Test778457" --category "script" "start"
/Users/quinn/Test778457 bsd 192.168.1.39 80
log emit --subsystem "com.example.Test778457" --category "script" "end"

Note I used a shell script because that’s what you’re doing. Normally I recommend that folks don’t use shell scripts when user-controlled privileges, like Local Network, are involve. I actually tested this without a shell script just to see what’d happen. I saw some weird behaviour that I struggled to reproduce. However, in none of my tests did I see this completely fix the issue.

I load the launchd property list as an agent:

% launchctl load com.example.Test778457.plist

And run a test by starting it:

% launchctl start com.example.Test778457

I monitor the results in Console.

Here’s what I saw:

  1. I started the job as described above, with it configured to run the BSD Sockets code.

  2. This did not display a local network alert and the connection failed with EHOSTUNREACH. So the BSD Sockets code can’t connect.

  3. I edited the script to change bsd to nw.

  4. I started the job again. This time it presented the local network alert and successfully connected. So the Network framework code can connect.

  5. I reverted the edits from step 3.

  6. I started the job again. This time the BSD Sockets code was able to connect.

So, something weird is happening here. AFAIK local network privacy shouldn’t change behaviour based on which networking API you’re using, but clearly it does. Using a shell script is an added complication, but there’s nothing fundamentally wrong about that. And eliminating the shell script didn’t get this working anyway.

I recommend that you file a bug about this. Once you’re done, post back here with the bug number and I’ll add my owns findings to your bug.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"


import Foundation
import Network
import os.log
import System
let log = Logger(subsystem: "com.example.Test778457", category: "tool")
func runNetworkFramework(_ host: String, _ port: UInt16) -> Never {
log.log("will connect NW, host: \("\(host)", privacy: .public), port: \("\(port)", privacy: .public)")
let connection = NWConnection(host: .init(host), port: .init(rawValue: port)!, using: .tcp)
connection.stateUpdateHandler = { newState in
switch newState {
case .ready:
log.log("did connect")
exit(0)
case .failed(let error):
log.log("did not connect, failed, error: \(error, privacy: .public)")
exit(1)
case .cancelled:
log.log("did not connect, cancelled")
exit(1)
case .setup, .waiting(_), .preparing: fallthrough
@unknown default: return
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
log.log("did not connect, timeout")
exit(1)
}
connection.start(queue: .main)
dispatchMain()
}
func runBSDSockets(_ host: String, _ port: UInt16) -> Never {
log.log("will connect BSD, host: \(host, privacy: .public), port: \(port)")
do {
let s = try FileDescriptor.socket(AF_INET, SOCK_STREAM, 0)
try s.connect(host, port)
log.log("did connect")
exit(0)
} catch {
log.log("did not connect, failed, error: \(error, privacy: .public)")
exit(1)
}
}
func main() {
let arg1 = CommandLine.arguments.dropFirst(1).first.map { String($0) }
let arg2 = CommandLine.arguments.dropFirst(2).first.map { String($0) }
let arg3 = CommandLine.arguments.dropFirst(3).first.map { String($0) }
let arg4 = CommandLine.arguments.dropFirst(4).first.map { String($0) }
enum API: String {
case nw
case bsd
}
guard
let apiStr = arg1,
let api = API(rawValue: apiStr),
let host = arg2,
let port = arg3.flatMap( { UInt16($0) } ),
arg4 == nil
else { exit(1) }
switch api {
case .nw: runNetworkFramework(host, port)
case .bsd: runBSDSockets(host, port)
}
}
main()

I suspect that this is a bug in local network privacy

Yet another. :)

Normally I recommend that folks don’t use shell scripts when user-controlled privileges, like Local Network, are involve

Not sure what else I could do instead. The actual script I'm trying to run is a nightly build script of our own code. It updates from source control, invokes xcodebuild, etc. etc. (Hilarious how because our source control server is in our building, this "protection" stymies us, but if I was connecting to some random North Korean IP, that'd be fine, no permission prompts then!)

This time the BSD Sockets code was able to connect

Cool. Perhaps that could be a workaround... we'll try...

Thanks very much for your detailed debugging and detailed reply! You have been so helpful to so many, for so long.

I've given up filing bugs with Apple, but for you I'll make an exception: FB16131937

Written by seanmcb in 831895022
Yet another.

On the plus side, this all keeps me gainfully employed (-:

Written by seanmcb in 831895022
FB16131937

Ta!

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Oh, while added my own comments to your bug I noticed that you’re reporting a lot of duplicate entries showing up in System Settings > Security & Privacy > Local Network. That almost certainly relates to this:

Written by DTS Engineer in 831898022
This is different from your case, because Homebrew uses ad-hoc signing. I was hoping that signing the code might help. It does not.

I was focused on the connection failure, which is why I signed my code using an Apple Development signing identity. That means that I didn’t see the duplicate entries that you reported in your bug. I still saw the connection issue though.

Anyway, you should be able to get around the duplicate entries issue by signing the code make these connections. I don’t know if Homebrew has a way to do that when it builds the code but, if not, you should try re-signing the final results.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

On the plus side, this all keeps me gainfully employed (-:

:) I think Apple would collapse without you Quinn!

The duplicate entries in System Settings (FB16131937) are unrelated to this script of ours or homebrew. They occur with our actual app's nightly builds (which this script makes). We sign and notarize builds we release to customers, but I don't recall exactly what we do with mere nightlies. They maybe use ad-hoc signing...

But back to the original issue... is there still no way to manually add an executable to the list of processes allowed to make local network connections?

Written by seanmcb in 832133022
is there still no way to manually add an executable to the list of processes allowed to make local network connections?

Correct.


Earlier I wrote:

Written by DTS Engineer in 831898022
I actually tested this without a shell script just to see what’d happen. I saw some weird behaviour that I struggled to reproduce

Yesterday I went back to try this out, and I discovered something interesting:

  • Sometimes my BSD Sockets code would present the local network alert correctly and everything would be fine.

  • Sometimes it would present the alert but fail to remember my decision. That is, the tool would show up as approved in System Settings > Privacy & Security > Local Network, but repeating the test would result in the connection still being blocked.

  • Sometimes I’d see something weird in that list, specifically, an app whose name was the name of my Mac [1].

Like I said, weird.

I was discussing my results with the folks who work on this stuff and they reminded me of an earlier issue that I discovered but had completely forgotten about. Local network privacy doesn’t deal well with processes that have a very short lifespan. I actually filed a bug about this during the macOS 15 beta cycle, and we made things better, but it’s not completely fixed.

In the test code I posted earlier, as soon as I get an error I log that and call exit. That’s the worst-case scenario for triggering this issue )-:

To check whether my results were being caused by that, I replaced my exit calls with calls to this function:

func exitSlowly(status: CInt) -> Never {
sleep(3)
exit(status)
}

And with that change, my tests started to behave consistently.

I’m not sure how this translates to your setup. You’re having problems with a tool coming from Homebrew, meaning its hard to change its exit behaviour. However, if you were able to do that, it’d be interesting to see how it affects the final behaviour of the system.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] Specifically, the user-assigned device name, as discussed here.

Sometimes my BSD Sockets code would present the local network alert correctly and everything would be fine.

Interesting, because we just saw this yesterday too (for the first time ever). The cron job runs overnight and when I Screen Shared to the Mac yesterday morning there was the permission screen waiting for me to press Allow. I pressed Allow but then was too busy with other things.

Looking today, the UI in System Settings now has an entry with a weird icon and the name of the Mac. Right-clicking to Reveal in Finder fails. It's like a zombie entry that falls back to a stupid icon and stupid string. I've just rebooted, and now that zombie entry is gone.

I’m not sure how this translates to your setup. You’re having problems with a tool coming from Homebrew, meaning its hard to change its exit behaviour. However, if you were able to do that, it’d be interesting to see how it affects the final behaviour of the system.

The actual thing we are doing is a nightly build script which uses svn/subversion (built by MacPorts) to get our latest code every night then build it with xcodebuild.

Perhaps when there are many/large file changes svn runs slowly enough to make this happen or not? In all our testing, the working copy was already up-to-date and thus it ran fast. Or maybe we could use Network Link Conditioner to force everything to run like molasses? Then again, since the local connection is rejected by the OS immediately, neither of those may help.

MacPorts can be told to build from source. I suppose I could hack svn to add a sleep()...

Written by seanmcb in 832399022
the UI in System Settings now has an entry with a weird icon and the name of the Mac.

Yeah, that sounds very familiar.

Written by seanmcb in 832399022
I've just rebooted, and now that zombie entry is gone.

I wasn’t aware of that. Cool.

Written by seanmcb in 832399022
I suppose I could hack svn to add a sleep()...

I supposed (-: However, there may be an easier way.

My experience is that once the system correctly records a local network state for your executable, everything works. So, you don’t need to fix this forever, you just need to get it working correctly once.

That means:

  • Signing your program. Without that, the system has a hard time tracking its identity.

  • Delaying its exit in the failing case.

You might be able to do the latter with LLDB.

Failing that, you could create a dummy program with the same designated requirement (DR) as the program you’re trying to get working. Have it trip the local network alert and then delay exiting. That’ll create the local network state for that program, and your real tool will benefit from that because it has the same DR.

See TN3127 Inside Code Signing: Requirements for more about DRs.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

So I found the lldb incantation to wait for a process:

sudo lldb -n svn -w

I then launched svn via my launchagent and indeed lldb attached to it, and indeed the launchagent waited. I waited minutes, then told lldb to continue, and it did, and then the launchagent also finished. But again svn output 'no route to host', and again no permission prompt appeared for me the whitelist svn.

I also updated to macOS 15.4, which has not helped.

Accepted Answer
Written by seanmcb in 833270022
So I found the lldb incantation to wait for a process:

Indeed.

Written by seanmcb in 833270022
I waited minutes, then told lldb to continue, and it did, and then the launchagent also finished.

Ah, I seem to have been insufficiently clear here. Sorry. The time that matters is the time between triggering the local network alert and then exiting. So adding time to the start of the process won’t help.

What might work is to set a breakpoint on exit and then continue. That’ll keep the process around after it bumps in to the local network privacy limit.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Hallelujah! Breaking on _exit() worked!

I guess it indeed boils down to the process not existing long enough.

Can you add this to TN3179? :)

Many thanks Quinn!

Written by seanmcb in 834174022
Can you add this to TN3179?

Would you believe it was in an early draft, but I took it out because my original bug about this was marked as fixed |-:

I’ll see what I can do about putting it back, or maybe getting this into the release notes.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Quinn, I've never looked at the svn source code, but being cross-platform, I image they use BSD-style socket APIs. Is there some error code that indicates failure due to 'local network' permission? If so, svn could be modified to call sleep(1) in such a case...

Written by seanmcb in 835302022
Is there some error code that indicates failure due to 'local network' permission?

No. The issue here is that BSD Sockets has a very limited set of errors that it’s allowed to return, which makes this infeasible. For example, I’d really like it to fail with EPERM here, but BSD Sockets code just isn’t set up for that.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Sequoia 'local network' permission failure from launch agent
 
 
Q