Environment and executable resolution
Processes started by launchd or from Finder receive a minimal PATH
(/usr/bin:/bin:/usr/sbin:/sbin). Agents installed with Homebrew, cargo,
bun, or ~/.local/bin are invisible to them. Pohunek resolves the search path
once, at pohunek service install, with one documented policy, and records the
result so every later start is deterministic.
Resolution order
Section titled “Resolution order”Highest priority first:
- A configured absolute executable. A program name containing
/must be absolute and executable; a relative one (./agent,bin/agent) is refused and no search happens. The daemon resolves agent programs (capability detection, create, resume) and the helper programs it runs itself (git, the worktree-hook interpretersh) through this same resolver and spawns the pinned path; the doctor’sbin:checks, the capability snapshot (git_available, worktree support, agent runtimes), and the CLI’s daemon lookup use the same resolver, so all of them agree on what would run, skipping empty and relativePATHentries (a trailing colon is harmless) rather than failing, and launches the exact canonical path it probed. - An explicitly supplied environment
PATH. A caller that already has a validatedPATH(a client launched from a shell passes its inherited one) uses it without discovery.pohunek service installsupplies none. - Bounded login-shell discovery (macOS only). One
$SHELL -l -cprobe printsPATHbetween two random sentinel lines through the absolute/usr/bin/printenv. It is never interactive (-iis not used), reads a null stdin, starts from an empty environment plusHOME,USER,LOGNAME,SHELL(the shell being probed, so a profile that branches on it behaves as in a real login), the profile selectorsZDOTDIRandXDG_CONFIG_HOMEwhen set (each must be an absolute UTF-8 path, or the install fails; a client that skips the probe names the cause), built by the one sharedshell_env::login_environment,TERM=dumb, and a baselinePATH, and runs in its own process group. One deadline (10 s) covers the fallback-directory validation, the executable checks, the probe, and the validation of the printed directories; output above 64 KiB kills the probe too. The group is killed as soon as the shell exits, so a background job left by a startup file cannot hold the output open. User startup output before or after the sentinels is ignored (the first sentinel starts on its own line, so a banner without a trailing newline cannot glue to it), and a decoy line cannot spoof the random sentinel. A login shell reads profile files but not.zshrc, so the trusted fallback directories it lacks are appended after the discovered ones. - A fallback directory list. Used alone when tier 3 is unavailable or
failed. The list is one table in
pohunek_platform::shell_env(DARWIN_FALLBACK_DIRECTORIES):~/.local/bin,~/.cargo/bin,~/.bun/bin,/opt/homebrew/{bin,sbin}(Apple Silicon Homebrew),/usr/local/{bin,sbin}(Intel Homebrew and vendor installers), then the system directories. No single Homebrew prefix is assumed.
An executable found by any tier (and the login shell itself) is judged on the
opened file: symlinks are resolved, then the final file must be a regular file
owned by the user or root, writable by neither group nor others, and without an
ACL that grants access (macOS), and the kernel must agree the user can run it.
A candidate that fails is skipped and the search goes on, like one the shell
cannot execute. The path is returned as found, not canonicalized, so a
multi-call binary (sh linked to bash or busybox) keeps the name it was
started by. The directories the lookup passes through must also be
owner-controlled, on the lexical chain and on the canonical chain of a symlink
target: every component passes the platform’s trusted-ancestor policy (user-owned
components not group- or world-writable, root-owned ones non-writable or
sticky), every symlink on the chain is owned by the user or root, and a
directory others can write (a sticky /tmp) may hold a chain entry only when
the entry is ours or root’s, so no other account can rename or retarget an
entry after the check. A
path-based exec cannot be made atomic with the check; the chain check removes
the attacker’s ability to change what the path names. A private 0700 directory
below the sticky root-owned /tmp passes. Group-writable executables or
directories, such as an admin-group Intel Homebrew, are out of scope. A refused configured
executable reports which check failed (Refusal: owner uid, mode, ACL, the
untrusted chain directory, or the errno of the open or execute check) so a
rejection is diagnosable. On macOS an ACL counts only when an allow entry
grants a change right (deny entries such as everyone deny delete do not). An
ACL that cannot be determined (any query error other than “no ACL”) is not
trusted: the candidate is refused with the errno.
Login-shell output and the fallback table are untrusted input, so a directory
is kept only when it is trusted: symlinks are resolved, every component of the
canonical path is owned by the user or root and not writable by others (the
platform’s trusted-ancestor rules), and the directory itself is not writable
by group or others. /tmp, other sticky world-writable directories, anything
below a writable ancestor, and group-writable directories (such as an
admin-group Intel /usr/local/bin, which is out of scope) are refused,
because another local account could plant an agent executable there. Each kept entry is recorded as its validated canonical path, so validation
and recording cover the same directory and nothing can be retargeted after the
check. The trade-off: a profile symlink that later points elsewhere (nix
generations, a dotfile manager) keeps the recorded target until the path is
recorded again; #319 tracks the refresh command. Empty, relative, .-style, duplicate, and
missing entries are ignored; a PATH containing a control character is
garbage and fails the probe.
Failure handling
Section titled “Failure handling”Discovery fails closed and never invents a value for required configuration:
| Login shell behaviour | Result |
|---|---|
| Hangs past the deadline | killed (process group), Timeout, fallback list used |
| Prints more than the output bound | killed, OutputTooLarge, fallback list used |
| Prints nothing or junk without the sentinels | MalformedOutput, fallback list used |
| Exits non-zero | Failed, fallback list used |
Prints a PATH with no usable directory |
UnusablePath, fallback list used |
$SHELL is set but not absolute |
pohunek service install fails (service_environment_invalid) |
$SHELL is unset |
/bin/zsh is used and the report says so |
The discovery environment is validated, and discovery run, before any install
effect, including before an interrupted install of another version is rolled
back (rolling back an interrupted upgrade needs no discovery), and
pohunek service check runs the same validation. If not even one trusted fallback directory exists, pohunek service install
fails with service_search_path_unavailable instead of starting a daemon with
an empty search path. A HOME, USER, or LOGNAME that is set but not UTF-8
fails the install rather than being left out of the probe environment.
Where the result goes
Section titled “Where the result goes”pohunek service install records the resolved directories in service.toml
under [environment] search_path and hands their :-joined value to the daemon
job as PATH (the launchd plist EnvironmentVariables). The daemon forwards
PATH to workers and agents through the existing [environment] allowlist,
which stays the only channel: no other variable is added, and no secret or
interactive environment is captured. Linux and systemd keep search_path = []:
the daemon inherits the user manager’s environment and no PATH is written.
The install result reports how the path was obtained: search_path.source
(login_shell, fallback, recorded for a resumed install, or unmanaged),
the recorded entries, shell_used and shell_defaulted, the typed
login_shell_failure when the fallback list was used, and dropped, the
directories refused as untrusted with a reason. The human output prints a
warning: line for a failed login shell and for each refused directory. This
is the only place the outcome is visible, so read it after installing.
The path is recorded at install. The job definition is written only at install
and at an upgrade to a different version, and upgrades reuse the recorded list
without running a login shell, so an edit of search_path in service.toml
takes effect only at the next version-changing upgrade; a same-version upgrade
returns before it replaces the job. To pick up a newly installed prefix,
uninstall and install again. A command that refreshes the path in place is
tracked in #319.
The NetBird CLI
Section titled “The NetBird CLI”The netbird CLI that overlay discovery and pohunek doctor run is resolved by
the same trusted-executable policy, in the process that runs it. The lookup
uses the process PATH first. On macOS a process launched from Finder or launchd
carries a minimal PATH, so a miss continues in the fixed fallback directories
(Homebrew and package-installer prefixes among them); only trusted directories
and executables count. The resolved absolute path is what runs, so the kernel
does no second search. A netbird that fails the policy is reported as missing,
and netbird_cli in the doctor agrees with the daemon’s own probe.
The nix-darwin system profile under /run/current-system/sw/bin is accepted
for netbird when its root-owned link traverses macOS’s trusted system run
directory. The resolved command uses the verified canonical target.
Safety rules
Section titled “Safety rules”- The login shell runs once, at install time, as the installing user. No user file is sourced in a privileged context and no command is ever run through a shell except this probe.
- Only one
-cscript is passed, built from a random sentinel and the absoluteprintenvpath; no untrusted value is interpolated into it. - Discovery output is bounded, validated, and never logged verbatim; errors carry reasons, not the printed value.
- An abandoned discovery (deadline passed) starts no shell and validates no further output; a filesystem call already stuck in the kernel cannot be cancelled, only abandoned.