让 agent 执行 sudo 时弹窗授权:sudo -A + pass + gpg-agent 免费方案
八月 04, 2026 更新: 八月 08, 2026 [网络与安全] #sudo #安全 #opencode #gpg需求
agent(如 opencode)执行 sudo 命令时,希望弹出一个授权窗口:
- 回车 = 允许,sudo 正常执行
- Esc = 取消,sudo 直接失败
- 全程不需要输入密码
背景:SSH 登录远程机器,opencode 在终端里跑,需要 root 权限时由用户人工把关。
sudo -A 机制
sudo -A 让 sudo 调用 SUDO_ASKPASS 指向的辅助程序来获取密码,而不是从终端读取:
SUDO_ASKPASS=/usr/local/libexec/sudo-askpass sudo -A <command>
askpass 程序把密码打到标准输出,sudo 拿来用。所以思路就是:askpass = 确认对话框 + 密码来源,密码来源不能让用户每次打字。
实测两个关键事实
动手之前先验证了环境,两个事实直接决定了方案走向:
# 1. askpass 进程的 stdio 是 /dev/null,没有终端
# 写一个探针 askpass 打印自己的 stdio:
$ printf '#!/bin/sh\necho "tty=$(tty) stdin=$(readlink /proc/$$/fd/0) disp=$DISPLAY"\nexit 1\n' > /tmp/t-askpass
$ chmod +x /tmp/t-askpass && SUDO_ASKPASS=/tmp/t-askpass sudo -A true
tty=不是一个 tty stdin=/dev/null disp=localhost:10.0
- askpass 没有 controlling tty,stdio 是
/dev/null→ 不能靠终端提示,得靠 X 弹窗或显式exec </dev/tty(后者在 tmux/后台场景不可靠) DISPLAY=localhost:10.0→ SSH 有 X11 转发,yad 对话框可以直接弹到本地屏幕,回车/Esc 可行
# 2. systemd-creds 在这台机器上不可用(无 TPM)
$ echo test | systemd-creds encrypt --name=test - /tmp/x.cred
Failed to encrypt: io.systemd.InteractiveAuthenticationRequired
本打算用 systemd-creds 做机器绑定的免费密码存储,无 TPM 直接出局。
方案选型
| 方案 | 免费 | SSH 可用 | 免打字 | 结论 |
|---|---|---|---|---|
| 1Password CLI | ❌ 付费 | ✅ | ✅ | 功能最好,要钱 |
| GNOME Keyring (secret-tool) | ✅ | ❌ 无 daemon | ❌ | SSH 会话里 keyring 未解锁,反而要输解锁密码 |
| systemd-creds | ✅ | ✅ | ❌ | 无 TPM 报错 |
| pass + gpg-agent | ✅ | ✅ | ✅ | 选中(主方案) |
| ssh-askpass-gnome | ✅ | 有限 | ❌ | 备选:GNOME 桌面可用,弹密码框 |
| 600 权限明文文件 | ✅ | ✅ | ✅ | 兜底,密码明文落盘 |
最终选择 pass + gpg-agent + yad:免费、SSH 下可用、配置一次后零输入,密码以 gpg 密文落盘。ssh-askpass-gnome 保留为备选,在桌面环境无 GPG 密钥时也能用。
为什么用 yad 而不是 zenity?
实测发现 zenity 4.1.90 是 GTK4 版,而 yad 0.40.0 是 GTK3 版,在本机(SSH 登录 + waypipe/X11 转发显示)环境下 GTK4 的窗口根本创建不出来:
ldd:zenity →libgtk-4,yad →libgtk-3WAYLAND_DEBUG=1:zenity 只 bind 到xdg_wm_base,从不创建xdg_surface/xdg_toplevel,不 commit → 窗口永远不出现,进程在 GTK 主循环挂死(超时 exit 124,即"执行失败")- yad 完整走
get_xdg_surface → xdg_toplevel → commit,窗口正常弹出 - 强制
GDK_BACKEND=x11也一样:zenity 窗口停在1x1 IsUnMapped,照样挂死
根因:GTK4 是底层重写,Wayland 窗口管理用了新协议(gtk_shell1、wl_compositor v6 等),比 GTK3 更严格。waypipe 是"按协议类型逐条解包重组"的代理(它只转发自己内置协议表认识的内容),GTK4 的新协议它 0.9.2 版没跟上,GTK4 又没有兜底降级,于是窗口静默消失。GTK3 容忍度高、能降级,所以 yad 稳定。
这不是接口"新旧"的错,而是 waypipe 这条转发链路跟不上 GTK4 的新接口。遇到 zenity 弹不出窗口的问题,先确认它是不是 GTK4 版(ldd /usr/bin/zenity | grep gtk),是的话直接换 yad。
方案原理
pass(passwordstore)是一个 shell 脚本工具,底层调用 gpg 加解密:
- 每个密码是一个独立的 gpg 加密文件(
~/.password-store/sudo.gpg) - 公钥加密 —
pass init <gpg-key-id>把该公钥设为接收者,pass insert sudo时用公钥加密,不碰私钥,所以写入时不弹任何提示 - 私钥解密 —
pass show sudo需要私钥,私钥未解锁时 gpg-agent 弹 pinentry 要 passphrase - 内存缓存 — gpg-agent 把解锁后的私钥缓存在内存(默认仅 10 分钟),缓存期内
pass show不再弹任何提示
这里有两个容易混淆的点,值得单独说:
- gpg-agent 缓存的不是 sudo 密码,而是 GPG 私钥的 passphrase(私钥解锁状态)。sudo 密码本身以密文形式躺在
sudo.gpg里。 - 所以真实体验:设置时输一次 sudo 密码;之后每个会话第一次 sudo 会弹一次 GPG passphrase(pinentry,不是 sudo 密码),配置
default-cache-ttl后基本一天只输一次。
为什么密文放磁盘、私钥才放内存?
这是公钥加密的核心设计,不是缺陷:
- 密文 = 公钥侧,可以随便存。
sudo.gpg被别人复制走也解不开,没有私钥就是垃圾,放磁盘零风险。 - 私钥 = 秘密侧,需要保护。加密形态落盘(
~/.gnupg/),解锁状态只留内存,gpg-agent 重启(重启/注销/gpgconf --kill gpg-agent)即清空。
和 ssh-agent 同理:公钥到处放,私钥只在 agent 内存里。
备选方案:ssh-askpass-gnome
主方案需要 pass + GPG 密钥,若环境里没有 GPG 密钥、或不想走公钥加密这套,可以用桌面环境自带的 ssh-askpass-gnome 兜底:
sudo apt install ssh-askpass-gnome
secret-tool store --label='sudo password' application sudo # 首次把 sudo 密码存入 GNOME 密钥环
用法(同样带 -k,强制每次弹窗):
SUDO_ASKPASS=/usr/bin/ssh-askpass sudo -A -k <command>
与主方案的取舍:
- ssh-askpass 弹的是普通密码输入框,你每次要输密码(或解锁密钥环);主方案是回车/Esc 确认框,免打字。
- 主方案不依赖 GNOME keyring daemon,SSH 会话下更可靠(参见方案选型里 keyring 在 SSH 下未解锁的问题)。
- 桌面(有 DISPLAY、有密钥环)上 ssh-askpass 开箱即用,作为应急备选足够。
完整配置
1. 安装 pass、pinentry 与 yad
sudo apt install pass pinentry-curses yad # yad 是 GTK3,替代 GTK4 的 zenity
2. 生成 GPG 密钥对(若没有)
gpg --full-generate-key
gpg --list-secret-keys # 记下 KEY_ID
3. 初始化 pass 并存入 sudo 密码
pass init <KEY_ID>
echo '你的sudo密码' | pass insert sudo # 只输这一次
4. 延长 gpg-agent 缓存
~/.gnupg/gpg-agent.conf:
default-cache-ttl 86400
pinentry-program /usr/bin/pinentry-curses
重启 agent 生效:gpgconf --kill gpg-agent
5. askpass 脚本
sudo-askpass.sh:
#!/bin/bash
# sudo -A 的 askpass:确认对话框 + 从 pass 取密码,全程免打字
PW=$(pass show sudo 2>/dev/null)
[ -z "$PW" ] && exit 1
if yad --question --title="sudo 授权请求" \
--text="Agent 请求运行 sudo 命令" \
--button="允许 (回车):0" \
--button="取消 (Esc):1"; then
echo "$PW"
else
exit 1
fi
回车 → yad 返回 0 → 输出密码,sudo 执行;Esc → 非 0 → exit 1,sudo 失败。
6. 安装(askpass 必须 root 所有、不可被他人写)
sudo 对 askpass 程序有安全检查,必须是 root 所有且不可被 group/other 写:
sudo install -o root -m 755 sudo-askpass.sh /usr/local/libexec/sudo-askpass
7. 让每次 sudo 都弹窗:加 -k,不要改 sudoers(关键细节)
sudo 默认缓存密码 5 分钟(timestamp_timeout=5),连续命令根本不会触发 askpass,"每次确认"就失效了。解决办法是让 agent 的命令带上 -k:
SUDO_ASKPASS=/usr/local/libexec/sudo-askpass sudo -A -k <command>
sudo -k 带命令时忽略已缓存的凭据,强制重新走 askpass,且认证成功后不把新凭据写回缓存——所以每次命令都会弹窗,而你终端里手动 sudo 的 5 分钟缓存完全不受影响。
不要为此设置 timestamp_timeout=0:它是全局的,连手动 sudo 的缓存一起关掉。脚本里连续调用的多个 sudo(apt、make install、systemctl 等)会反复要密码,非交互环境(无 tty、无 askpass)里 sudo 直接失败。
8. agent 侧使用
opencode 等 agent 执行命令时带(注意 -k):
SUDO_ASKPASS=/usr/local/libexec/sudo-askpass sudo -A -k <command>
常见错误(别再犯)
错误 1:updatestartuptty 前忘了 export GPG_TTY(本次踩的坑)
错误写法(~/.zshrc 或 run.sh 里的经典配置):
export SSH_AUTH_SOCK="$(gpgconf --list-dirs agent-ssh-socket)"
gpg-connect-agent updatestartuptty /bye # ← GPG_TTY 没导出
现象:ssh-add ~/.ssh/id_ed25519 输完原密钥口令后卡住,过一会儿报 Could not add identity ... agent refused operation。而 pass show sudo 等 gpg 路径完全正常——这正是迷惑性最强的地方。
根因(两层,缺一不可):
updatestartuptty依赖客户端先发OPTION ttyname=...,而这个值来自GPG_TTY环境变量。没导出GPG_TTY,startup_tty 永远是空,但gpg-connect-agent照样返回 OK,静默失败。- gpg 路径(
pass/gpg)每次连接都会自带 ttyname,所以 pinentry 正常弹窗;ssh 路径(ssh-add)走 SSH 协议,不带任何 tty 信息,只能靠 gpg-agent 的全局 startup_tty。startup_tty 为空 → gpg-agent(systemd 守护进程,无控制终端)拉起 pinentry-curses 时无处画界面 → 报Inappropriate ioctl for device→ 拒绝操作。
正确写法(顺序不能反):
export SSH_AUTH_SOCK="$(gpgconf --list-dirs agent-ssh-socket)"
export GPG_TTY="$(tty)" # 必须先导出
gpg-connect-agent updatestartuptty /bye >/dev/null 2>&1
排查定位:怀疑 pinentry 时开 gpg-agent debug 日志复现,抓 gpg-agent 日志里的 error calling pinentry: 对设备不适当的 ioctl 操作 <Pinentry>,即可确认是 tty 没传到位,而非 pinentry 本身的问题。
错误 2:把两次 pinentry 提示当成"卡住"
ssh-add 导入有口令密钥会弹两次 pinentry:第一次是 ssh-add 读原密钥文件的原口令,第二次是 gpg-agent 为 private-keys-v1.d/ 副本新设保护口令(提示语"请输入一个密码,以便于在 gpg-agent 的密钥存储中保护接收到的私钥")。第二次只在无 tty 时才是故障;正常有 tty 时会出现密码 + 重复确认两个输入框,输完即成功。
更多原理:为什么经典 ssh-agent 从不错位弹窗、gpg-agent 会,以及多终端弹窗错位问题,见
notes/2025-07-18-gpg.md的"与经典 ssh-agent 的差异"一节。
安全边界
- 人工闸门在 yad 对话框:agent 无法静默执行 sudo,必须用户按回车。这是整套方案的核心价值。
- pass 密文、GPG 私钥本地加密,
~/.password-store/与~/.gnupg/默认权限即可。 - 不要用
pam_ssh_agent_auth这类免密 sudo:SSH agent 转发下,远程 agent 能拿到 agent socket,等于无声无息获得 root,确认闸门形同虚设。
总结
sudo -A+ askpass 是标准机制,askpass 程序 = 确认对话框 + 密码来源- 免费方案:yad(GTK3)确认 + pass(gpg 公钥加密)存密码 + gpg-agent 内存缓存私钥
- 别用 zenity(GTK4):本机 waypipe/转发显示环境下窗口创建失败(不建 xdg_surface、X11 下卡 1x1),弹不出对话框且进程挂死;用 yad(GTK3)替代
ssh-askpass-gnome留作备选:无 GPG 密钥时在桌面环境弹密码框兜底- 三个实测结论:askpass 无 tty 只能用 X 弹窗;SSH X11 转发可用;无 TPM 时 systemd-creds 不可用
- 用
sudo -A -k而不是改timestamp_timeout=0:-k只对 agent 的命令强制弹窗、不写回缓存,手动 sudo 的缓存不受影响