mcpbeat Sign in

Macos Lsenvironment Path Pin Agent Skill

Declaring 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.

1k tokens
context cost
the whole folder, loaded on every use
1
files
instructions only
0
copies elsewhere
how many repositories repackaged it
100
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/maxrave-dev/kotlin-footguns --skill macos-lsenvironment-path-pin

The instruction itself

3 sections, as written by the author

The environment dictionary pins PATH

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 PATH to /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 open

launches — 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 machinecommand -v <tool> — and check the answer is

inside /usr/bin, /bin, /usr/sbin or /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 what PATH became.

  • 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.

How to use it

Copy the folder

Take maxrave-dev/macos-lsenvironment-path-pin from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.