# How to verify an SSH helper before launch

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:

```c
#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:

1. The gateway resolves `/Applications/Example.app/Contents/Helpers/runner` and validates file A.
2. A competing process renames file B onto that pathname.
3. The gateway asks `Process` or `posix_spawn` to execute the pathname.
4. 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:

```swift
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.
