The environment dictionary pins PATH
SkillDev toolsDeclaring a Launch Services environment dictionary (`LSEnvironment`) in a packaged macOS app's property list pins the process `PATH` to the four bare system directories, so every external process the app spawns loses everything installed elsewhere. Use before adding any environment variable to a macOS app bundle, when a helper the app shells out to reports "command not found" only for installed users, or when a feature that works from a terminal launch silently does nothing from the Dock.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the The environment dictionary pins PATH skill
What this skill tells your AI
The instructions your AI receives, as published by maxrave-dev/kotlin-footguns in skills/macos-lsenvironment-path-pin/SKILL.md and read by ahel’s review.
A packaged macOS app can carry environment variables in its property list under LSEnvironment —
the Launch Services environment dictionary. It is the normal way to set an allocator flag, a
runtime toggle or a debug switch for a shipped app.
It has one side effect that is easy to miss and expensive to find:
Declaring the dictionary at all pins the process
PATHto/usr/bin:/bin:/usr/sbin:/sbin.
Not any particular key — the presence of the dictionary. Anything the app then starts as an
external process sees only those four directories. Tools installed by a package manager, in the
user's home, or anywhere under /usr/local are simply not found.
Two properties of the dictionary decide when it applies:
- It is applied by Launch Services, so it covers file-manager, Dock, Spotlight and
openlaunches — every normal launch of an installed app. - It is not applied when you run
Contents/MacOS/<binary>directly from a shell. That invocation inherits your shell environment instead.
Which means: you cannot reproduce this from a terminal. The way you test a desktop app during development is precisely the launch path that does not have the pin.
# adapted — packaging config, macOS section
mac {
# Declaring this dict pins PATH to /usr/bin:/bin:/usr/sbin:/sbin.
# Audited before adding — see the note in the audit section below.
info-plist.LSEnvironment.<KEY> = "<value>"
}
Traps
The audit is a precondition, not a follow-up. Before adding the key, find every external process the app can start on the macOS path and record, for each, why it is safe. That note is the artefact — it is what stops the next person redoing the audit, and what tells them when the answer has changed.
Platform-gated spawns still belong in the audit. In the case above the only spawn on the macOS code path was a probe gated to another OS, so the answer was "unreachable here". Write that down rather than leaving the call site unexplained; a later change that removes the gate is otherwise invisible.
Some platform APIs never consult PATH, and flagging them wastes the audit. Opening a URL or
a file through the desktop-integration API goes through Launch Services, which resolves the
handler without PATH. Those hits are false positives — but only if you have confirmed the call
really goes through that API and not through a spawned launcher command as a fallback. A
per-OS fallback chain that shells out is exactly the kind of code that gets added later.
The symptom does not name PATH. It arrives as "command not found" from a helper, a feature
that quietly does nothing, or a launch that fails only for users who installed the app normally.
If the spawn failure is swallowed — a runCatching with no log — there is no symptom at all,
just a dead feature.
Removing the key is the wrong rollback if you still want its effect. Dropping the dictionary
restores normal PATH inheritance and loses whatever you added it for. Prefer adding PATH
explicitly to the same dictionary, with the directories your helpers actually live in appended to
the four bare ones.
Absolute paths are the durable fix. For anything you ship or can locate deterministically,
spawn it by absolute path or pass an explicit environment to the process builder, so the app does
not depend on PATH at all. Then the pin costs nothing.
This is macOS-specific. The Windows and Linux packaging paths have their own environment mechanisms with their own rules; do not carry this conclusion across.
Verifying it
-
Find every spawn site and record a verdict for each:
grep -rn "ProcessBuilder\|Runtime.getRuntime().exec\|exec(" \ --include='*.kt' --include='*.java' <src> -
Resolve each command on a clean machine —
command -v <tool>— and check the answer is inside/usr/bin,/bin,/usr/sbinor/sbin. Anything else will not be found once the dictionary is declared. -
Confirm the key reached the packaged app. Read the property list out of the built bundle rather than trusting the packaging config; a key written at the wrong nesting level is accepted silently by many config formats and never appears in the output.
-
Test from the file manager, not the terminal. Launch the installed app the way a user would, then print the running process's environment (
ps eww -p <pid>) and confirm both that your variable is present and whatPATHbecame. -
Re-run step 1 whenever a feature adds an external process, and keep the audit note beside the config key so the connection is discoverable from either end.
Signals
- GitHub stars
- 315
- Forks
- 6
- Last commit
- Aug 2026
Advanced
- Catalog kind
- skill
- Gateway key
macos-lsenvironment-path-pin- Source
- github.com/maxrave-dev/kotlin-footguns