让 agent 执行 sudo 时弹窗授权:sudo -A + pass + gpg-agent 免费方案

八月 04, 2026 更新: 八月 08, 2026 [网络与安全] #sudo #安全 #opencode #gpg

需求

agent(如 opencode)执行 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
# 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 的窗口根本创建不出来:

根因: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 加解密:

  1. 每个密码是一个独立的 gpg 加密文件(~/.password-store/sudo.gpg)
  2. 公钥加密 — pass init <gpg-key-id> 把该公钥设为接收者,pass insert sudo 时用公钥加密,不碰私钥,所以写入时不弹任何提示
  3. 私钥解密 — pass show sudo 需要私钥,私钥未解锁时 gpg-agent 弹 pinentry 要 passphrase
  4. 内存缓存 — gpg-agent 把解锁后的私钥缓存在内存(默认仅 10 分钟),缓存期内 pass show 不再弹任何提示

这里有两个容易混淆的点,值得单独说:

为什么密文放磁盘、私钥才放内存?

这是公钥加密的核心设计,不是缺陷:

和 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>

与主方案的取舍:

完整配置

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 路径完全正常——这正是迷惑性最强的地方。

根因(两层,缺一不可):

  1. updatestartuptty 依赖客户端先发 OPTION ttyname=...,而这个值来自 GPG_TTY 环境变量。没导出 GPG_TTY,startup_tty 永远是空,但 gpg-connect-agent 照样返回 OK,静默失败。
  2. 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 的差异"一节。

安全边界

总结