Native macOS dialogs for two things OpenSSH normally only surfaces on a terminal:
- FIDO2 / security-key (SK) user-presence — a "touch your key" prompt.
- Agent key-use confirmation — the Allow/Deny prompt for keys loaded with
ssh-add -c.
It works with the Apple-shipped binaries (/usr/bin/ssh, ssh-agent,
ssh-add, OpenSSH_10.2p1) — no rebuild required. Those binaries retain the
full SSH_ASKPASS code path; macOS just doesn't ship an askpass program (the
compiled default /usr/X11R6/bin/ssh-askpass doesn't exist here). This is that
missing program plus the glue to wire it into a launchd agent.
| File | What it is |
|---|---|
ssh-askpass-ui |
Pure-AppleScript (#!/usr/bin/osascript) askpass program. |
de.sektioneins.ssh-agent-ui.plist |
LaunchAgent template: a private ssh-agent with askpass enabled. |
install.sh |
Installs/uninstalls the askpass and the LaunchAgent for the current user. |
README.md |
This file. |
OpenSSH execs the askpass program with the prompt as argv[1] and selects the
behaviour through the SSH_ASKPASS_PROMPT environment variable. The script
maps each mode to a native dialog:
SSH_ASKPASS_PROMPT |
OpenSSH caller | Dialog | Result contract |
|---|---|---|---|
none |
SK touch (notify_start) |
persistent "SSH Security Key" notice | stays up until OpenSSH SIGTERMs it on completion |
confirm |
ssh-add -c, mux prompts (ask_permission) |
Deny / Allow | yes + exit 0 = allow; any non-zero exit = deny |
| (unset) | passphrase / FIDO2 PIN (read_passphrase) |
hidden-answer field | secret printed to stdout, exit 0 |
Two rules from OpenSSH's readpass.c shape the whole design:
- Askpass is only used when there is no controlling tty.
notify_startwrites to the terminal ifstderris a tty; it only spawns askpass when there isn't one. Assh-agentstarted by launchd has no tty, so it uses askpass. Plain interactivesshin Terminal does have a tty — see Scope & limitations. - macOS needs an explicit "display" signal. Both notification and
passphrase paths require
DISPLAY/WAYLAND_DISPLAYto be set orSSH_ASKPASS_REQUIRE=force. There is noDISPLAYon macOS, so the agent's environment must setSSH_ASKPASS_REQUIRE=forcealongsideSSH_ASKPASS.
Run install.sh from the repo checkout:
./install.sh # link ~/bin/ssh-askpass-ui to this checkout (default)
./install.sh --copy # or: install a standalone copyThe script:
- links (or copies) the askpass program to
~/bin/ssh-askpass-ui; - writes
~/Library/LaunchAgents/de.sektioneins.ssh-agent-ui.plistfrom the template in this repo and fills in your absolute paths, since launchd does not expand~; - (re)loads the agent and checks that it answers on
~/.ssh/agent-ui.sock.
Run it again after changing the plist template. Reloading the agent drops the
keys loaded into it, so ssh-add them again afterwards.
An existing ~/bin/ssh-askpass-ui is never replaced. If it is already
there, it must be a link to this checkout's ssh-askpass-ui or an identical,
executable copy of it. Anything else (a link elsewhere, an outdated or modified
copy) stops the script before it touches the agent. Remove the file yourself,
or run ./install.sh --uninstall, to switch between link and copy or to
replace an outdated copy.
Why ~/bin and not ~/.local/bin. Package managers and installers such as
uv tool, pipx or the claude installer routinely drop and replace
executables in ~/.local/bin. Keeping the askpass out of that directory means
no tool update can overwrite or replace the program your agent runs on every
confirm/PIN prompt by accident. See Security notes.
Link or copy? Linking is the default, because the link keeps the askpass current with wherever it was installed from:
- Git checkout: the program is
exec'd fresh on every prompt, so agit pulltakes effect on the next dialog — no agent reload needed (you only re-runinstall.shwhen the plist changes). Git stores the script as mode100755, so the executable bit survives clone/pull, and files git writes locally get nocom.apple.quarantine, so updates won't reintroduce the Gatekeeper "could not verify" prompt. - Package install: the link points to the path
install.shwas run from, without resolving symlinks. Run it through the package manager's stable path (e.g. Homebrew'sopt/link, not the versionedCellar/directory) and upgrades update the askpass too.
A copy (--copy) is a standalone snapshot that never changes on its own. To
update it, remove it and run ./install.sh --copy again.
Security: the askpass runs in your ssh-agent's context on every confirm/PIN
prompt, so the link target needs the same trust as ~/bin. install.sh
refuses to link to a directory or program that you don't own or that is
group/world-writable. Don't link into a shared/multi-user checkout.
Why a private agent. The system agent (com.openssh.ssh-agent) can't be
used for this: its plist lives under SIP-protected
/System/Library/LaunchAgents, so you can't add environment variables to it.
And forcing askpass globally via launchctl setenv SSH_ASKPASS_REQUIRE force
would leak into every GUI-session process, pushing your interactive ssh
passphrase prompts into popups too. A dedicated agent keeps the askpass
behaviour contained.
To check on the agent:
launchctl print gui/$(id -u)/de.sektioneins.ssh-agent-ui | grep -E 'state|SSH_ASKPASS'There are two ways, and you'll usually want both — they cover different tools and don't conflict.
A. IdentityAgent in ~/.ssh/config (for ssh/scp/sftp). This is the
robust, shell-independent way: it applies even to GUI apps that shell out to
ssh, regardless of $SSH_AUTH_SOCK.
Host *
IdentityAgent ~/.ssh/agent-ui.sockIdentityAgent overrides $SSH_AUTH_SOCK, supports ~, the %d/%h/%u
tokens and $VAR expansion, and accepts none to disable agent use. But it
is read only by ssh — ssh-add and ssh-agent ignore it entirely.
B. SSH_AUTH_SOCK in ~/.zshrc (for ssh-add). ssh-add only ever uses
this env var, so you need it to load keys into the private agent:
export SSH_AUTH_SOCK="$HOME/.ssh/agent-ui.sock"Open a new shell (or source ~/.zshrc) and confirm:
echo "$SSH_AUTH_SOCK" # -> /Users/<user>/.ssh/agent-ui.sock
ssh-add -l # talks to the private agent (empty list at first)If you set only IdentityAgent and skip the export, prefix ssh-add calls
explicitly instead: SSH_AUTH_SOCK=~/.ssh/agent-ui.sock ssh-add -c ….
Load a key with -c so the agent prompts on every use:
ssh-add -c ~/.ssh/id_ed25519 # ordinary key, confirm each useNow each authentication pops a Deny / Allow dialog. Allow approves that one signature; Deny (or Escape / closing the window) refuses it.
FIDO2/SK keys usually don't need -c. By default an SK key requires a
physical touch for every signature, and the agent shows the presence dialog
(below) each time, so the touch already is the per-use confirmation. Adding
-c just puts an extra Allow/Deny click in front of the touch.
The exception is a key created with ssh-keygen -O no-touch-required: the
agent signs with it silently, with no dialog and no touch. Always load those
with -c:
ssh-add -c ~/.ssh/id_ed25519_sk # no-touch-required SK key: confirm each useLoad the SK key into the agent so signing happens in the (ttyless) agent:
ssh-add ~/.ssh/id_ed25519_sk
ssh you@host # "SSH Security Key — confirm presence" dialog appears; touch the keyIf the key also has a PIN, you'll get the hidden-answer PIN dialog first.
SSH_ASKPASS_PROMPT=none ~/bin/ssh-askpass-ui "Touch your security key" # presence notice (close it)
SSH_ASKPASS_PROMPT=confirm ~/bin/ssh-askpass-ui "Allow use of key X?"; echo "exit=$?" # Allow->exit 0, Deny->1
SSH_ASKPASS_PROMPT= ~/bin/ssh-askpass-ui "Enter passphrase" # secret echoed to stdout- Interactive
sshwith an on-disk SK key still prompts on the terminal, not the GUI. Whensshruns in Terminal it has a tty, and OpenSSH sends the touch prompt there (notify_startonly escalates to askpass in batch mode). Fix: load the SK key into the askpass-enabled agent (above) so signing goes through the ttyless agent and the prompt becomes a dialog. (The only other way is a source patch tonotify_start— relevant only if you rebuild this tree.) - A GUI (Aqua) session is required.
osascript display dialogneeds the user's window server. It works for a launchd GUI-domain agent and for Terminal, but not over a headless/remote SSH-in session. - The presence dialog auto-dismisses after 300s as a safety net so an abandoned prompt can't linger; normally OpenSSH closes it as soon as the touch lands.
theseal/ssh-askpass is a macOS askpass implementation with a similar goal. Key differences:
| Feature | theseal/ssh-askpass | askpass-ui |
|---|---|---|
| Agent strategy | launchctl setenv to inject askpass into the system ssh-agent, then stops/restarts it |
Runs a private launchd agent with SSH_ASKPASS_REQUIRE=force; system agent untouched |
| Prompt scope | Handles ssh host-key prompts too (host key ...) |
Only the three SSH_ASKPASS_PROMPT modes: none, confirm, passphrase/PIN |
| FIDO2/SK touch | Dialog auto-closes after 15 s timeout | Stays up until OpenSSH SIGTERMs it (300 s safety net) |
| Button defaults | "OK" is the default for simple dialogs | Deny is default — fails closed for confirmation prompts |
| Sudo support | Also sets SUDO_ASKPASS |
SSH-only |
| Install complexity | Requires stopping the system agent | Zero impact on system agent; parallel setup |
The askpass program is intentionally pure AppleScript:
- No shell is ever invoked — no
do shell script, no subprocess. There is no command line to inject into. - The prompt (
argv[1]) andSSH_ASKPASS_PROMPTare consumed as string values passed straight todisplay dialog; they are never concatenated into executable code, so a hostile prompt (server banner, key comment) can only ever be displayed, never executed. - The secret is written only to stdout (the pipe OpenSSH provides) — never into an env var, argv, temp file, or log.
confirmfails closed — Deny is the cancel button and there is no default button, so Escape, closing the window, or a stray Enter all deny; only a deliberate click on Allow approves key use.- Installed outside
~/.local/bin— the agent runs whatever fileSSH_ASKPASSpoints to, so that path should only change when you change it.~/.local/binis shared with installers (uv tool,pipx,claude, …) that write and replace executables there on their own;~/binkeeps the askpass out of their way. This guards against accidental replacement, not against malware: anything running as your user can still write to~/bin.
./install.sh --uninstallThis unloads the agent and removes the plist and ~/bin/ssh-askpass-ui. Then
remove the SSH_AUTH_SOCK export from ~/.zshrc and the
Host * / IdentityAgent block from ~/.ssh/config.