How to verify an SSH helper before launch
Learn how to verify an SSH helper by checking its bundle path, signature, ownership, file identity, and launch-time code requirement on macOS.

A local gateway should treat its SSH helper as privileged code, even when the helper has no vault and keeps no state. The helper receives a command, inherits file descriptors and environment, and may become the process that talks to a remote host. If an attacker can substitute that executable, every approval made above it becomes irrelevant.
The safe design has two different jobs. Preflight checks diagnose a damaged or badly installed bundle before any request reaches the runner. A launch-time code requirement asks macOS to refuse the actual process image if its signing identity is wrong. Confusing those jobs creates the familiar gap where an app verifies one file and executes another.
Derive the helper from the running bundle
Resolve the helper from the app bundle you are actually running, never from PATH, the current directory, a preference, or a path supplied by the agent. The expected location must be a build-time constant. A gateway that searches for sp-ssh has already allowed its environment to choose executable code.
Apple documents standard bundle locations for nested code, including Contents/MacOS and Contents/Helpers. Pick one location, put only code there, and make the build fail if the helper lands elsewhere. Sign the helper first and the outer app last. That order lets the outer signature seal its nested code reference.
Foundation can locate an auxiliary executable, but the security decision still needs an exact relationship to the main bundle. Resolve symbolic links and standardize both URLs, then compare path components, not string prefixes. A string test such as candidate.path.hasPrefix(bundle.path) accepts siblings such as /Applications/Good.app.backup and can behave badly around case or normalization. The candidate must equal the one expected URL under the running bundle.
Do not accept a helper copied beside the app as a fallback. A fallback is attractive during development because it makes broken packaging less annoying. In production it silently changes the trust boundary from signed bundle contents to whatever happens to occupy a nearby pathname. Development builds should use an explicit build configuration and fail loudly when packaging is wrong.
Location is evidence about packaging, not identity. An attacker who can replace a file inside a writable bundle can keep the same path. That is why the next checks inspect the opened object and its signature.
Open first, then inspect file identity
Open the candidate with O_NOFOLLOW, keep the descriptor open, and call fstat on that descriptor. This order matters. Calling lstat, checking the result, and later calling open gives another process a chance to replace the directory entry between those operations.
Apple's Secure Coding Guide recommends descriptor based operations for this reason. It specifically calls for checking type, UID, GID, mode, and link count after opening. For an executable helper, I use a preflight shaped like this:
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <errno.h>
int inspect_helper(const char *path, uid_t expected_uid, struct stat *snapshot) {
int fd = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
if (fd < 0) return -1;
struct stat st;
if (fstat(fd, &st) != 0 ||
!S_ISREG(st.st_mode) ||
st.st_uid != expected_uid ||
(st.st_mode & (S_IWGRP | S_IWOTH)) != 0 ||
(st.st_mode & S_IXUSR) == 0 ||
st.st_nlink != 1) {
int saved = errno ? errno : EPERM;
close(fd);
errno = saved;
return -1;
}
*snapshot = st;
return fd;
}
expected_uid comes from the installation model, not from the file itself. A system-managed install may require root ownership. A per-user app may legitimately belong to that user. Do not write a check that demands UID 0 simply because root looks trustworthy. Apple also warns that a path may cross onto another mounted filesystem, where ownership alone says less than developers often assume.
The link-count rule deserves a conscious decision. A regular bundled executable normally has one hard link, so rejecting any other count is reasonable. If your packaging system intentionally creates hard links, document that fact and test the expected count. Dropping the check because a build once surprised you leaves another name through which the same inode may be altered.
Keep the descriptor and the stat snapshot until launch completes. They do not make a path based spawn atomic, but they let you detect replacement, record the device and inode involved, and explain a refusal without reopening an attacker-controlled name.
Check every writable directory in the chain
A perfect helper mode does not protect a helper whose parent directory can be renamed or rewritten. The attacker does not need permission to edit the executable bytes when they can replace the directory entry that points to those bytes.
Walk from the helper's parent back to the bundle root with directory descriptors. For each component, reject symbolic links, confirm that it is a directory, record its device and inode, and apply the ownership and write-permission rule for your install model. openat and fstatat with a held parent descriptor are better tools than repeatedly resolving absolute strings. The traversal should stop at the already verified bundle root, not at a path that merely has a familiar suffix.
This is where per-user installs require honest language. If the same user who runs the gateway owns a writable app bundle, another process running as that user may be able to replace bundle files. Mode and owner checks can reveal accidental exposure to other accounts, but they do not defend against a full compromise of the current account. The code-signing identity check still matters because an attacker cannot satisfy your designated requirement merely by applying an ad hoc signature.
Also compare device IDs along the expected chain if your policy requires a local, single-volume bundle. Apple notes that a pathname can cross a mount boundary. Rejecting unexpected boundaries is useful, but do not pretend it proves that a volume is trustworthy. It only proves that the object is not where your packaging contract said it would be.
Directory checks catch mundane deployment faults surprisingly well: an updater that leaves a group-writable staging directory, a helper restored outside the signed app, or a symbolic link introduced by a developer script. These failures should stop the runner before it receives a command. Continuing with a warning turns a packaging error into executable selection logic.
Verify identity, not merely signature validity
A valid signature answers whether signed code remains consistent with that signature. It does not answer whether your team signed the code or whether the executable is your helper. On macOS, code can be ad hoc signed, and a different developer can produce perfectly valid signed code.
Use Code Signing Services with an explicit requirement that names the expected signing identifier and team identity for the distribution channel. Apple TN3127 makes the distinction sharp: a code-signing identifier is a name chosen by the signer, a signing identity contains a certificate and private key, and a designated requirement expresses what counts as the same code across releases. Comparing a displayed Authority string is a diagnostic shortcut, not a product security boundary.
Create a SecStaticCode for the absolute helper URL, compile or load the requirement, and call SecStaticCodeCheckValidityWithErrors. Include kSecCSStrictValidate and kSecCSCheckAllArchitectures. Apple documents that the default may validate only the native slice of a universal binary. Validating every slice prevents an untested architecture from carrying a different or broken signature.
Do not use kSecCSBasicValidateOnly for this job. That flag skips validation of the main executable and resources, which defeats an integrity check. Do not rely on the helper's own designated requirement without comparing it to a requirement controlled by the gateway. Self-description is not authorization.
The outer app deserves validation too. Apple places helper tools in standard nested-code locations so signing machinery can treat them as code. A correctly signed release has a chain of intent: the helper satisfies its own expected requirement, and the outer app's seal records the nested component. Checking both catches a helper that was independently valid but transplanted into a damaged bundle.
Return the detailed CFError internally and map it to a small refusal category for users: missing signature, requirement mismatch, invalid resource, unsupported architecture, or changed file. Never turn a validation error into a retry through another path. A code-signing failure says the runner is unavailable.
A static check cannot close the launch race
Static validation is conditional on the file remaining unchanged. Apple's documentation for SecStaticCodeCheckValidity states this directly and calls out dynamic filesystems such as network, union, and FUSE filesystems. The warning also applies to an ordinary replaceable directory entry: once validation returns, another process may rename a different executable over the checked path before posix_spawn resolves it.
The failure can be walked in four moves:
- The gateway resolves
/Applications/Example.app/Contents/Helpers/runnerand validates file A. - A competing process renames file B onto that pathname.
- The gateway asks
Processorposix_spawnto execute the pathname. - The kernel opens file B, because the spawn call receives a name rather than the descriptor held for file A.
Comparing stat before and after validation narrows and detects many attempts, but it does not make steps two and three indivisible. Hashing the file yourself has the same limitation and duplicates work already represented by the code signature. A lock file only coordinates processes that agree to honor the lock. An attacker will not.
A common recommendation is to validate once at app startup and cache success. It is popular because signature checks have a cost and bundled code appears immutable during a normal run. It is wrong for a gateway. Updates, restores, volume changes, and deliberate replacement can all occur while a menu-bar app remains alive. Cache the compiled requirement object if needed, as Apple suggests, but evaluate the executable at each launch boundary.
An open descriptor still helps. Hold it across validation, take another fstat immediately before spawning, and reject a changed device, inode, size, modification time, or change time. Take a third snapshot after process creation for telemetry. These comparisons improve diagnosis and raise the work required for an attacker, yet the security claim must remain accurate: only an operating-system launch requirement binds identity evaluation to the process launch.
Bind the requirement to the process creation
On systems that provide LightweightCodeRequirements, set Process.launchRequirement before calling run. Apple says that if the launching executable does not satisfy the LaunchCodeRequirement, the operating system does not run the process and creates a crash report. This moves the decisive identity check into process creation, after pathname selection can no longer drift away from the decision.
The core Swift setup is small:
import Foundation
import LightweightCodeRequirements
func configuredProcess(helper: URL, team: String, identifier: String) throws -> Process {
let requirement = try LaunchCodeRequirement.allOf {
ValidationCategory(.developerID)
TeamIdentifier(team)
SigningIdentifier(identifier)
}
let process = Process()
process.executableURL = helper
process.launchRequirement = requirement
return process
}
The team and identifier must come from release configuration compiled into the signed gateway. Do not load them from preferences beside the helper. If you ship through more than one signing channel, create and test an explicit requirement for each supported channel instead of weakening one expression until every build passes.
Apply the requirement to a Mach-O helper, not a shell wrapper. Apple notes that for a shebang script, a launch requirement evaluates the interpreter. A check that proves /bin/bash is Apple code says nothing about the script bytes you meant to trust. Put scripting logic into signed executable code, or treat the script as data sealed by and consumed inside trusted code without launching it as the privileged runner.
Feature-gate this API using its SDK availability and keep the static preflight for diagnostics. For older deployment targets, POSIX_SPAWN_START_SUSPENDED can create a child suspended before it begins user-space execution. You can obtain a dynamic SecCode for that PID, validate it against your requirement, then resume or kill it. That fallback is more delicate: check every return code, prevent PID confusion, close unintended descriptors, and never send a command or credential before validation succeeds. If your threat model cannot accept that complexity, require an operating system version that supports launch requirements.
Launch with a clean and narrow process contract
Verifying the binary does not sanitize what you give it. Build the argument vector from structured fields, set an explicit environment, choose the working directory deliberately, and pass only the descriptors the helper needs. Never invoke a shell to assemble an SSH command.
Start with an empty or allowlisted environment. Variables that affect dynamic loading, configuration discovery, locale, proxies, or home-directory lookup can change a signed program's behavior without changing its code. The hardened runtime and library validation reduce some loading attacks, but they do not turn an arbitrary inherited environment into a safe interface.
Treat standard input and output as a protocol. Define maximum message sizes, reject trailing fields, put a deadline on the exchange, and distinguish a helper protocol error from an SSH exit status. A stateless helper should not read keys from environment variables, arguments, temporary files, or an agent-provided path. It should receive the minimum request over a controlled channel and return the minimum result.
Close every unrelated descriptor. O_CLOEXEC on files opened during preflight is useful, but audit the rest of the app too. A child that inherits the vault database descriptor, an IPC listener, or a log file has gained access that no signature check was meant to grant. Set resource limits where the helper contract permits them, and terminate the entire child process group on cancellation or timeout.
Log the decision inputs without logging secrets: resolved bundle-relative location, signing requirement version, device and inode snapshots, validation result, child PID, and termination reason. That record should say which executable launch was authorized. Logging only the textual path loses the fact that one name may have referred to several files during an incident.
Put verification inside the authorization sequence
Helper acceptance belongs in the same transaction as action authorization, before the gateway releases any capability to the child. Checking the helper when the app opens is too early. Checking it after a person approves a remote command is too late if failure handling can leak the command, open a socket, or trigger a fallback.
Model one launch as a state machine with explicit, irreversible boundaries. The gateway receives and parses an action while the request has no access to credentials. It confirms that the vault or credential source is available, resolves and preflights the helper, obtains any required human authorization, and then performs the launch with the code requirement attached. Only after launch succeeds does it create the minimal credential or connection channel that the verified child needs. A failure in any earlier state destroys the pending action.
The exact placement of user approval depends on what the approval means. If the card asks whether a particular agent may perform a particular SSH command, it can appear before the relatively expensive launch. The approved command must remain immutable afterward, and a helper verification failure must consume or cancel that approval rather than queue it for a later binary. If approval asserts trust in the process that will execute the action, show it only after the gateway knows the candidate identity. Either design can work, but the audit record must connect request digest, authorization decision, signing requirement, and child process as one attempt.
Do not expose a secret merely because process creation returned success. Prepare pipes or sockets before spawning if the API requires that, but keep the credential-bearing end closed or logically gated. Wait until launch enforcement reports success, record the child identity, and then send the request. When the child needs an SSH key operation, prefer a narrow signing or connection interface over copying private key bytes into its address space. The less authority the helper receives, the smaller the cost of a mistake in every preceding check.
Cancellation needs the same care. A user can revoke a session while helper validation is running, or the agent can disconnect after approval but before spawn. Recheck the authorization generation immediately before launch and again before releasing the request. If it changed, terminate the child and close its channels. This is not a filesystem race, but it is another check/use gap around the same security decision.
Concurrent launches must not share mutable launch configuration. Give every attempt its own immutable helper URL, requirement object reference, argument vector, environment, descriptors, deadline, and audit identifier. A global Process template that one thread mutates while another calls run can send an approved request to the wrong executable or with the wrong environment even if both binaries are valid. Synchronize the small state transition that consumes approval and starts the configured process, not the full lifetime of every SSH connection.
A useful invariant is that no child-controlled byte enters the gateway's privileged state before identity enforcement succeeds. The helper may write to a pipe as soon as it runs, so keep its output parser detached until acceptance, cap the pipe buffer exposure, and treat early output as a protocol violation. Likewise, do not use an unverified child's error text to construct a privileged path, select a credential, or decide which binary to try next.
This order also produces a clearer journal. One attempt can show request_received, candidate_preflight_passed, authorization_granted, launch_requirement_passed, request_released, and a terminal result. Missing transitions become visible. A record that jumps from approval to an SSH exit code leaves responders unable to tell whether the expected runner ever handled the command.
Verify the child you actually started
Launch enforcement should be the decisive gate, but post-launch observation still catches integration errors and gives incident responders a stable process identity. Once run succeeds, collect the PID and obtain a dynamic SecCode for the running process. Check it against the corresponding process requirement before attaching the protocol parser or releasing sensitive input.
Apple's distinction between SecStaticCode and SecCode is useful here. A static object describes code on disk and is not inherently linked to running code. A dynamic code object represents code loaded into a process. These checks answer different questions: the static check explains whether the installed candidate is intact, while the dynamic check confirms the identity macOS assigned to the child that exists now.
PID-only lookup has sharp edges. PIDs are reused, and a short-lived child can exit between run, lookup, and validation. Treat failure to obtain or validate the code object as a launch failure. Where an IPC API provides an audit token, use that stronger process reference instead of accepting a PID supplied in a message. Never trust the child to tell you its own PID, signing identifier, or executable path.
The post-launch check must not become the only check on a platform where the child receives CPU first. A substituted process can act in the interval between spawn and inspection. Starting suspended before user-space execution reduces that gap on older systems, but the implementation must resume only the validated PID and must kill it on every error path. Launch requirements are simpler because the operating system refuses a mismatch before the process runs.
After acceptance, retain a process handle and tie every protocol message to that instance. Do not reopen a named socket or reconnect to a helper endpoint that another process could occupy. When the child exits, close all channels, invalidate the action, and require a fresh verified launch for the next process. A valid first child does not authorize a later child that happens to reuse its PID or endpoint name.
Record both installed and running evidence. For the installed object, keep the bundle-relative path, device, inode, size, timestamps, and static validation outcome. For the process, keep the PID, signing requirement revision, launch enforcement result, dynamic validation outcome, start time, and termination status. These values are not secrets. They let you distinguish a corrupt release, an update collision, a requirement configuration error, and attempted substitution without dumping commands or credentials.
Process identity also needs a lifetime rule. If the helper executes another program, the verified identity does not automatically transfer to the new image. Avoid designs in which a signed wrapper validates correctly and then calls arbitrary ssh through PATH. If an exec transition is part of the design, the final executable needs its own enforced requirement and fixed path, or the trusted helper must perform the required protocol itself. A signature on a launcher says nothing about the program it selects later.
Watch for libraries and configuration too. The running main executable can satisfy its requirement while unsafe environment variables or writable search locations alter its behavior. Sign dependent code, enable the hardened runtime options appropriate to the helper, use library validation where compatible, and remove search paths from the environment. Keep configuration as validated input from the gateway rather than allowing the child to discover dotfiles in a home directory.
Finally, test what your telemetry claims. In a replacement-race test, the static snapshot should identify file A, launch enforcement should either start file A or reject file B, and the dynamic check should agree with the launch result. If a log can report that A passed while an unrecorded B ran, the evidence model still follows the pathname instead of the process.
Fail closed without making updates impossible
Every verification failure should stop that command runner and leave the rest of the app in a comprehensible state. Do not fall back to system ssh, search another directory, remove the launch requirement, or ask the user to approve an unidentified helper. Approval cannot repair executable identity.
Updates need a separate state transition. Stop accepting new SSH work, let active children finish or terminate them, install the complete signed bundle with an atomic replacement mechanism, then rerun all bundle, file, and signature checks. A helper and app from different releases may each have valid signatures while violating the protocol contract between them. Include a protocol version handshake after launch and reject mismatches before sending an action.
Decide which changes require user-visible recovery. A missing helper after a partial update calls for reinstalling the app. A team or identifier mismatch may indicate tampering and deserves a stronger message. A mode mismatch may come from a backup tool. Internally retain the exact error; externally give a concise action that does not train people to bypass the check.
Test replacement behavior, not just happy-path signatures. Your release suite should cover a symlink at the helper path, a second hard link, group-writable parents, an ad hoc signed substitute, a correctly signed binary from the wrong identifier, a universal file with a damaged non-native slice, replacement between preflight and launch, and an update while the app stays running. A test hook that pauses after static validation makes the race reproducible instead of dependent on luck.
Sallyport uses a bundled stateless Go helper for its SSH channel, while the vault core stays inside the signed menu-bar app. That split keeps secrets out of the helper, but it does not make helper verification optional: the gateway must still prove which command runner starts before it hands over an approved action.
The acceptance rule should fit on one line in a design review: the expected file in the running bundle passes descriptor and directory checks, satisfies the release signing requirement, and the process creation enforces that same identity. If the platform cannot enforce the final clause, document the weaker guarantee and reduce what the child can receive.
FAQ
Why is checking the SSH helper path not enough?
A path is a name, not a stable file identity. Another process can replace the directory entry after your check, so the gateway must inspect the opened file and enforce a code requirement when the process launches.
Should a macOS app find its helper through PATH?
No. PATH lets the environment choose executable code and makes packaging failures look like successful discovery. Derive one exact helper URL from the running app bundle and refuse every fallback.
What file metadata should the gateway check?
After opening with O_NOFOLLOW, use fstat to check that the object is a regular executable file with the expected owner, safe write permissions, and the expected hard-link count. Keep its device and inode for later comparison and logging.
Does a valid code signature prove the helper is mine?
No. Validity proves consistency with a signature, and macOS can run code signed by other developers or signed ad hoc. Apply an explicit requirement for the expected signing identifier, team, and distribution category.
Why validate all architectures in a universal helper?
Apple says static validation normally checks only the native architecture. kSecCSCheckAllArchitectures makes the validator inspect every slice, including one that a different machine or an explicit architecture selection could run.
Can an open file descriptor eliminate the replacement race?
It stabilizes the file you inspect, but ordinary Process and posix_spawn calls still select the executable by path. Use the descriptor for snapshots and diagnosis, then rely on a launch-time requirement to bind identity to process creation.
What does Process.launchRequirement add?
It asks the operating system to evaluate a LaunchCodeRequirement for the executable during launch. If the executable fails, the operating system does not run the process, which closes the gap left by a static path check.
Are ownership and permissions still useful with code signing?
Yes, because they catch unsafe installation states and reduce opportunities for replacement. They are supporting evidence, though, and do not replace signer identity or launch-time enforcement.
Should the app cache a successful helper verification?
Cache the compiled requirement if measurement shows that it helps, but validate the executable at every launch boundary. A long-running app can outlive an update, restore, mount change, or deliberate file replacement.
How should the gateway react when helper verification fails?
Refuse that runner, log the exact internal reason, and offer recovery appropriate to the failure, such as reinstalling a damaged app. Never search for another SSH binary or downgrade the requirement to keep the action moving.